1use crate::analytic_surface::{circumcenter, AnalyticSurface};
2use crate::topology::{BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, VertexRecord};
3use crate::{make_arc, KernelTolerances, NurbsCurve, NurbsSurface, Vec3};
4use rustc_hash::FxHashMap as HashMap;
5
6#[path = "step/pcurve.rs"]
7mod pcurve;
8use pcurve::{build_pcurve, EmittedCurve, EmittedFrame, EmittedSurface, Pcurve2d};
9#[path = "step/pmi.rs"]
10pub(crate) mod pmi;
11pub use pmi::StepPmi;
12#[path = "step/assembly.rs"]
13pub mod assembly;
14pub use assembly::{
15 export_step_assembly, export_step_assembly_report, StepAssemblyExport, StepExportOccurrence,
16 StepExportProduct,
17};
18#[path = "step/export_tree.rs"]
19mod export_tree;
20pub use export_tree::{assembly_export_tree, PART_ATTRIBUTES, PART_NUMBER};
21#[path = "step/styles.rs"]
22pub(crate) mod styles;
23pub use styles::StepColors;
24
25pub use crate::step_matrix::Mat4;
26pub(crate) use crate::step_matrix::{mat4_mul, MAT4_IDENTITY};
27
28pub(crate) fn transform_point(matrix: &Mat4, point: Vec3) -> Vec3 {
29 crate::AffineTransform {
30 elements: *matrix,
31 }
32 .point(point)
33}
34
35pub(crate) fn step_string(value: &str) -> String {
36 value.replace('\'', "''")
37}
38
39pub(crate) fn real(value: f64) -> Result<String, String> {
40 if !value.is_finite() {
41 return Err(format!("export_step: non-finite number {value}"));
42 }
43 if value == 0.0 {
46 return Ok("0.".into());
47 }
48 if value.fract() == 0.0 && value.abs() < 1e15 {
49 return Ok(format!("{value:.0}."));
50 }
51 let mut output = format!("{value:.15}");
52 while output.ends_with('0') {
53 output.pop();
54 }
55 if output.ends_with('.') {
56 output.push('0');
57 }
58 if output == "-0.0" {
59 output = "0.0".into();
60 }
61 Ok(output)
62}
63
64fn knot_runs(knots: &[f64]) -> (Vec<f64>, Vec<usize>) {
65 let mut values = Vec::new();
66 let mut multiplicities = Vec::new();
67 for &knot in knots {
68 if values
69 .last()
70 .is_some_and(|previous: &f64| (*previous - knot).abs() <= 1e-12)
71 {
72 *multiplicities.last_mut().unwrap() += 1;
73 } else {
74 values.push(knot);
75 multiplicities.push(1);
76 }
77 }
78 (values, multiplicities)
79}
80
81pub(crate) fn edge_subcurve(edge: &EdgeRecord) -> Result<NurbsCurve, String> {
82 let [start, end] = edge.curve.domain()?;
83 let epsilon = (1e-9 * (end - start)).max(2e-9);
84 let mut curve = edge.curve.clone();
85 if edge.t0 > start + epsilon && edge.t0 < end - epsilon {
86 curve = curve.split(edge.t0)?.1;
87 }
88 let domain = curve.domain()?;
89 if edge.t1 < domain[1] - epsilon && edge.t1 > domain[0] + epsilon {
90 curve = curve.split(edge.t1)?.0;
91 }
92 Ok(curve)
93}
94
95#[derive(Default)]
96pub(crate) struct StepWriter {
97 lines: Vec<String>,
98}
99
100impl StepWriter {
101 pub(crate) fn add(&mut self, body: impl Into<String>) -> usize {
102 let id = self.lines.len() + 1;
103 self.lines.push(format!("#{id}={};", body.into()));
104 id
105 }
106
107 fn data(&self) -> String {
108 self.lines.join("\n")
109 }
110}
111
112pub(crate) fn write_point(writer: &mut StepWriter, point: Vec3) -> Result<usize, String> {
113 Ok(writer.add(format!(
114 "CARTESIAN_POINT('',({},{},{}))",
115 real(point.x)?,
116 real(point.y)?,
117 real(point.z)?
118 )))
119}
120
121pub(crate) fn id_list(ids: &[usize]) -> String {
122 format!(
123 "({})",
124 ids.iter()
125 .map(|id| format!("#{id}"))
126 .collect::<Vec<_>>()
127 .join(",")
128 )
129}
130
131pub(crate) fn write_direction(writer: &mut StepWriter, direction: Vec3) -> Result<usize, String> {
132 Ok(writer.add(format!(
133 "DIRECTION('',({},{},{}))",
134 real(direction.x)?,
135 real(direction.y)?,
136 real(direction.z)?
137 )))
138}
139
140pub(crate) fn write_placement(
141 writer: &mut StepWriter,
142 origin: Vec3,
143 axis: Vec3,
144 ref_direction: Vec3,
145) -> Result<usize, String> {
146 let origin = write_point(writer, origin)?;
147 let axis = write_direction(writer, axis)?;
148 let ref_direction = write_direction(writer, ref_direction)?;
149 Ok(writer.add(format!(
150 "AXIS2_PLACEMENT_3D('',#{origin},#{axis},#{ref_direction})"
151 )))
152}
153
154fn write_analytic_surface(
163 writer: &mut StepWriter,
164 surface: &NurbsSurface,
165) -> Result<Option<(usize, bool, EmittedSurface)>, String> {
166 let Some(analytic) = surface.analytic() else {
167 return Ok(None);
168 };
169 match analytic {
170 AnalyticSurface::Plane {
171 origin,
172 u_dir,
173 v_dir,
174 ..
175 } => {
176 let (Ok(normal), Ok(x_axis)) = (u_dir.cross(*v_dir).normalized(), u_dir.normalized())
179 else {
180 return Ok(None);
181 };
182 let placement = write_placement(writer, *origin, normal, x_axis)?;
183 Ok(Some((
188 writer.add(format!("PLANE('',#{placement})")),
189 false,
190 EmittedSurface::Plane {
191 origin: *origin,
192 x_axis,
193 y_axis: normal.cross(x_axis),
194 },
195 )))
196 }
197 AnalyticSurface::RuledRevolution {
198 frame,
199 rho0,
200 rho1,
201 height,
202 } => {
203 let flipped = *height < 0.0;
207 let radius_scale = 1.0 + rho0.abs().max(rho1.abs());
208 if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
209 if *rho0 <= 0.0 {
210 return Ok(None);
211 }
212 let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
213 return Ok(Some((
214 writer.add(format!(
215 "CYLINDRICAL_SURFACE('',#{placement},{})",
216 real(*rho0)?
217 )),
218 flipped,
219 EmittedSurface::Cylinder {
220 frame: EmittedFrame {
221 origin: frame.origin,
222 x_axis: frame.x_axis,
223 y_axis: frame.y_axis,
224 axis: frame.axis,
225 azimuth_sign: 1.0,
226 },
227 radius: *rho0,
228 },
229 )));
230 }
231 let slope = (rho1 - rho0) / height;
237 let apex_margin = 2.0 * (1e-4 * height.abs().max(1.0) + 1e-9) * slope.abs();
238 if rho0.min(*rho1) <= apex_margin {
239 return Ok(None);
240 }
241 let axis = if slope >= 0.0 {
245 frame.axis
246 } else {
247 frame.axis.scale(-1.0)
248 };
249 let placement = write_placement(writer, frame.origin, axis, frame.x_axis)?;
250 Ok(Some((
251 writer.add(format!(
252 "CONICAL_SURFACE('',#{placement},{},{})",
253 real(*rho0)?,
254 real(slope.abs().atan())?
255 )),
256 flipped,
257 EmittedSurface::Cone {
258 frame: EmittedFrame {
259 origin: frame.origin,
260 x_axis: frame.x_axis,
261 y_axis: axis.cross(frame.x_axis),
265 axis,
266 azimuth_sign: if slope >= 0.0 { 1.0 } else { -1.0 },
267 },
268 radius: *rho0,
269 semi_angle: slope.abs().atan(),
270 },
271 )))
272 }
273 AnalyticSurface::Sphere { frame, radius } => {
274 let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
277 Ok(Some((
278 writer.add(format!(
279 "SPHERICAL_SURFACE('',#{placement},{})",
280 real(*radius)?
281 )),
282 false,
283 EmittedSurface::Sphere {
284 frame: EmittedFrame {
285 origin: frame.origin,
286 x_axis: frame.x_axis,
287 y_axis: frame.y_axis,
288 axis: frame.axis,
289 azimuth_sign: 1.0,
290 },
291 radius: *radius,
292 },
293 )))
294 }
295 AnalyticSurface::Torus {
296 frame,
297 major_radius,
298 minor_radius,
299 } => {
300 let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
301 Ok(Some((
302 writer.add(format!(
303 "TOROIDAL_SURFACE('',#{placement},{},{})",
304 real(*major_radius)?,
305 real(*minor_radius)?
306 )),
307 false,
308 EmittedSurface::Torus {
309 frame: EmittedFrame {
310 origin: frame.origin,
311 x_axis: frame.x_axis,
312 y_axis: frame.y_axis,
313 axis: frame.axis,
314 azimuth_sign: 1.0,
315 },
316 major_radius: *major_radius,
317 minor_radius: *minor_radius,
318 },
319 )))
320 }
321 AnalyticSurface::Revolution { .. } => Ok(None),
324 }
325}
326
327struct CircularArc {
332 center: Vec3,
333 axis: Vec3,
334 x_axis: Vec3,
335 y_axis: Vec3,
338 radius: f64,
339 sweep: f64,
341 spans: usize,
345}
346
347fn curve_scale(curve: &NurbsCurve) -> f64 {
348 curve
349 .control_points
350 .iter()
351 .map(|p| (p.x.abs() / p.w).max(p.y.abs() / p.w).max(p.z.abs() / p.w))
352 .fold(0.0, f64::max)
353}
354
355fn curves_match(a: &NurbsCurve, b: &NurbsCurve, scale: f64) -> bool {
359 if a.degree != b.degree
360 || a.knots.len() != b.knots.len()
361 || a.control_points.len() != b.control_points.len()
362 {
363 return false;
364 }
365 if a.knots
366 .iter()
367 .zip(&b.knots)
368 .any(|(x, y)| (x - y).abs() > 1e-12)
369 {
370 return false;
371 }
372 let tolerance = 1e-9 * scale.max(1.0);
373 a.control_points
374 .iter()
375 .zip(&b.control_points)
376 .all(|(p, q)| {
377 (p.x - q.x).abs() <= tolerance
378 && (p.y - q.y).abs() <= tolerance
379 && (p.z - q.z).abs() <= tolerance
380 && (p.w - q.w).abs() <= 1e-9
381 })
382}
383
384fn recognize_circular_arc(curve: &NurbsCurve) -> Option<CircularArc> {
389 if curve.degree != 2
390 || curve.control_points.len() < 3
391 || curve.control_points.len() % 2 == 0
392 || (curve.control_points.len() - 1) / 2 > 4
393 {
394 return None;
395 }
396 let [t0, t1] = curve.domain().ok()?;
397 let at = |fraction: f64| curve.evaluate(t0 + (t1 - t0) * fraction);
398 let p0 = at(0.0).ok()?;
401 let pa = at(0.35).ok()?;
402 let pb = at(0.7).ok()?;
403 let center = circumcenter(p0, pa, pb)?;
404 let radial = p0.sub(center);
405 let radius = radial.length();
406 let scale = curve_scale(curve);
407 if radius <= 1e-9 * scale.max(1.0) {
408 return None;
409 }
410 let x_axis = radial.scale(1.0 / radius);
411 let axis = radial.cross(pa.sub(center)).normalized().ok()?;
412 let y_axis = axis.cross(x_axis);
413 let p_end = at(1.0).ok()?;
414 let sweep = if p_end.sub(p0).length() <= 1e-9 * (1.0 + radius) {
415 std::f64::consts::TAU
416 } else {
417 let closing = p_end.sub(center);
418 let mut angle = closing.dot(y_axis).atan2(closing.dot(x_axis));
419 if angle < 0.0 {
420 angle += std::f64::consts::TAU;
421 }
422 angle
423 };
424 let rebuilt = make_arc(center, x_axis, y_axis, radius, 0.0, sweep).ok()?;
425 curves_match(curve, &rebuilt, scale).then_some(CircularArc {
426 center,
427 axis,
428 x_axis,
429 y_axis,
430 radius,
431 sweep,
432 spans: (curve.control_points.len() - 1) / 2,
433 })
434}
435
436fn write_analytic_curve(
443 writer: &mut StepWriter,
444 curve: &NurbsCurve,
445) -> Result<Option<(usize, EmittedCurve)>, String> {
446 if curve.degree == 1
447 && curve.control_points.len() == 2
448 && curve
449 .control_points
450 .iter()
451 .all(|control| (control.w - 1.0).abs() <= 1e-12)
452 {
453 let start = curve.control_points[0].point()?;
454 let end = curve.control_points[1].point()?;
455 let Ok(direction) = end.sub(start).normalized() else {
456 return Ok(None);
457 };
458 let point = write_point(writer, start)?;
459 let step_direction = write_direction(writer, direction)?;
460 let vector = writer.add(format!(
461 "VECTOR('',#{step_direction},{})",
462 real(end.sub(start).length())?
463 ));
464 return Ok(Some((
465 writer.add(format!("LINE('',#{point},#{vector})")),
466 EmittedCurve::Line { start, end },
470 )));
471 }
472 if let Some(arc) = recognize_circular_arc(curve) {
473 let placement = write_placement(writer, arc.center, arc.axis, arc.x_axis)?;
477 return Ok(Some((
478 writer.add(format!("CIRCLE('',#{placement},{})", real(arc.radius)?)),
479 EmittedCurve::Circle {
480 center: arc.center,
481 x_axis: arc.x_axis,
482 y_axis: arc.y_axis,
483 radius: arc.radius,
484 sweep: arc.sweep,
485 spans: arc.spans,
486 },
487 )));
488 }
489 Ok(None)
490}
491
492fn write_curve(writer: &mut StepWriter, curve: &NurbsCurve) -> Result<usize, String> {
493 let points = curve
494 .control_points
495 .iter()
496 .map(|control| write_point(writer, control.point()?))
497 .collect::<Result<Vec<_>, _>>()?;
498 write_bspline_curve(writer, curve, &points)
499}
500
501fn write_bspline_curve(
506 writer: &mut StepWriter,
507 curve: &NurbsCurve,
508 points: &[usize],
509) -> Result<usize, String> {
510 let (knot_values, multiplicities) = knot_runs(&curve.knots);
511 let multiplicities = format!(
512 "({})",
513 multiplicities
514 .iter()
515 .map(usize::to_string)
516 .collect::<Vec<_>>()
517 .join(",")
518 );
519 let knots = format!(
520 "({})",
521 knot_values
522 .iter()
523 .map(|value| real(*value))
524 .collect::<Result<Vec<_>, _>>()?
525 .join(",")
526 );
527 let rational = curve
528 .control_points
529 .iter()
530 .any(|control| (control.w - 1.0).abs() > 1e-12);
531 if !rational {
532 return Ok(writer.add(format!(
533 "B_SPLINE_CURVE_WITH_KNOTS('',{},{},.UNSPECIFIED.,.F.,.F.,{multiplicities},{knots},.UNSPECIFIED.)",
534 curve.degree,
535 id_list(points),
536 )));
537 }
538 let weights = format!(
539 "({})",
540 curve
541 .control_points
542 .iter()
543 .map(|control| real(control.w))
544 .collect::<Result<Vec<_>, _>>()?
545 .join(",")
546 );
547 Ok(writer.add(format!(
548 "(BOUNDED_CURVE()B_SPLINE_CURVE({},{},.UNSPECIFIED.,.F.,.F.)\
549 B_SPLINE_CURVE_WITH_KNOTS({multiplicities},{knots},.UNSPECIFIED.)\
550 CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE({weights})\
551 REPRESENTATION_ITEM(''))",
552 curve.degree,
553 id_list(points),
554 )))
555}
556
557fn write_point_2d(writer: &mut StepWriter, point: [f64; 2]) -> Result<usize, String> {
558 Ok(writer.add(format!(
559 "CARTESIAN_POINT('',({},{}))",
560 real(point[0])?,
561 real(point[1])?
562 )))
563}
564
565fn write_direction_2d(writer: &mut StepWriter, direction: [f64; 2]) -> Result<usize, String> {
566 Ok(writer.add(format!(
567 "DIRECTION('',({},{}))",
568 real(direction[0])?,
569 real(direction[1])?
570 )))
571}
572
573fn write_pcurve_geometry(writer: &mut StepWriter, curve: &Pcurve2d) -> Result<usize, String> {
583 match curve {
584 Pcurve2d::Line { point, vector } => {
585 let magnitude = vector[0].hypot(vector[1]);
586 if magnitude <= 0.0 {
587 return Err("export_step: degenerate 2D line pcurve".into());
588 }
589 let point_id = write_point_2d(writer, *point)?;
590 let direction =
591 write_direction_2d(writer, [vector[0] / magnitude, vector[1] / magnitude])?;
592 let vector_id = writer.add(format!(
593 "VECTOR('',#{direction},{})",
594 real(magnitude)?
595 ));
596 Ok(writer.add(format!("LINE('',#{point_id},#{vector_id})")))
597 }
598 Pcurve2d::Circle {
599 center,
600 ref_direction,
601 radius,
602 } => {
603 let center_id = write_point_2d(writer, *center)?;
604 let direction = write_direction_2d(writer, *ref_direction)?;
605 let placement = writer.add(format!(
606 "AXIS2_PLACEMENT_2D('',#{center_id},#{direction})"
607 ));
608 Ok(writer.add(format!("CIRCLE('',#{placement},{})", real(*radius)?)))
609 }
610 Pcurve2d::Spline(spline) => {
611 let points = spline
612 .control_points
613 .iter()
614 .map(|control| {
615 let point = control.point()?;
616 write_point_2d(writer, [point.x, point.y])
617 })
618 .collect::<Result<Vec<_>, String>>()?;
619 write_bspline_curve(writer, spline, &points)
620 }
621 }
622}
623
624fn write_pcurve_entity(
627 writer: &mut StepWriter,
628 surface_id: usize,
629 context_2d: usize,
630 curve: &Pcurve2d,
631) -> Result<usize, String> {
632 let geometry = write_pcurve_geometry(writer, curve)?;
633 let representation = writer.add(format!(
634 "DEFINITIONAL_REPRESENTATION('',(#{geometry}),#{context_2d})"
635 ));
636 Ok(writer.add(format!("PCURVE('',#{surface_id},#{representation})")))
637}
638
639fn write_surface(writer: &mut StepWriter, surface: &NurbsSurface) -> Result<usize, String> {
640 let rows = surface
641 .control_points
642 .iter()
643 .map(|row| {
644 row.iter()
645 .map(|control| write_point(writer, control.point()?))
646 .collect::<Result<Vec<_>, _>>()
647 .map(|ids| id_list(&ids))
648 })
649 .collect::<Result<Vec<_>, _>>()?;
650 let grid = format!("({})", rows.join(","));
651 let (u_values, u_multiplicities) = knot_runs(&surface.knots_u);
652 let (v_values, v_multiplicities) = knot_runs(&surface.knots_v);
653 let multiplicities = |values: &[usize]| {
654 format!(
655 "({})",
656 values
657 .iter()
658 .map(usize::to_string)
659 .collect::<Vec<_>>()
660 .join(",")
661 )
662 };
663 let knots = |values: &[f64]| -> Result<String, String> {
664 Ok(format!(
665 "({})",
666 values
667 .iter()
668 .map(|value| real(*value))
669 .collect::<Result<Vec<_>, _>>()?
670 .join(",")
671 ))
672 };
673 let u_mults = multiplicities(&u_multiplicities);
674 let v_mults = multiplicities(&v_multiplicities);
675 let u_knots = knots(&u_values)?;
676 let v_knots = knots(&v_values)?;
677 let rational = surface
678 .control_points
679 .iter()
680 .flatten()
681 .any(|control| (control.w - 1.0).abs() > 1e-12);
682 if !rational {
683 return Ok(writer.add(format!(
684 "B_SPLINE_SURFACE_WITH_KNOTS('',{},{},{grid},.UNSPECIFIED.,.F.,.F.,.F.,\
685 {u_mults},{v_mults},{u_knots},{v_knots},.UNSPECIFIED.)",
686 surface.degree_u, surface.degree_v,
687 )));
688 }
689 let weights = format!(
690 "({})",
691 surface
692 .control_points
693 .iter()
694 .map(|row| {
695 row.iter()
696 .map(|control| real(control.w))
697 .collect::<Result<Vec<_>, _>>()
698 .map(|values| format!("({})", values.join(",")))
699 })
700 .collect::<Result<Vec<_>, _>>()?
701 .join(",")
702 );
703 Ok(writer.add(format!(
704 "(BOUNDED_SURFACE()B_SPLINE_SURFACE({},{},{grid},.UNSPECIFIED.,.F.,.F.,.F.)\
705 B_SPLINE_SURFACE_WITH_KNOTS({u_mults},{v_mults},{u_knots},{v_knots},.UNSPECIFIED.)\
706 GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE({weights})\
707 REPRESENTATION_ITEM('')SURFACE())",
708 surface.degree_u, surface.degree_v,
709 )))
710}
711
712fn write_length_unit(writer: &mut StepWriter, unit: &str) -> Result<usize, String> {
713 let normalized = unit.to_lowercase();
714 if normalized == "meter" || normalized == "metre" {
715 return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.))"));
716 }
717 if normalized == "centimeter" || normalized == "centimetre" {
718 return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))"));
719 }
720 if matches!(normalized.as_str(), "micron" | "micrometer" | "micrometre") {
721 return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MICRO.,.METRE.))"));
722 }
723 if normalized == "inch" || normalized == "foot" {
724 let metre = writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.))");
725 let (factor, name) = if normalized == "inch" {
726 (0.0254, "INCH")
727 } else {
728 (0.3048, "FOOT")
729 };
730 let measure = writer.add(format!(
731 "LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{metre})",
732 real(factor)?
733 ));
734 return Ok(writer.add(format!(
735 "(CONVERSION_BASED_UNIT('{name}',#{measure})LENGTH_UNIT()NAMED_UNIT(*))"
736 )));
737 }
738 Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))"))
739}
740
741fn vertex_for(solid: &BrepSolid, id: u64) -> Result<&VertexRecord, String> {
742 solid
743 .vertices
744 .iter()
745 .find(|vertex| vertex.id == id)
746 .ok_or_else(|| format!("export_step: missing vertex {id}"))
747}
748
749fn edge_for(solid: &BrepSolid, id: u64) -> Result<&EdgeRecord, String> {
750 solid
751 .edges
752 .iter()
753 .find(|edge| edge.id == id)
754 .ok_or_else(|| format!("export_step: missing edge {id}"))
755}
756
757fn surface_key(face: &FaceRecord) -> usize {
758 face as *const FaceRecord as usize
759}
760
761struct CoedgeUse<'a> {
765 surface_key: usize,
766 face: &'a FaceRecord,
767 coedge: &'a CoedgeRecord,
768}
769
770#[derive(Clone, Debug, Default)]
779pub struct StepExportReport {
780 pub text: String,
782 pub pcurves_written: usize,
785 pub pcurves_omitted: usize,
788 pub surface_curves: usize,
790 pub seam_curves: usize,
793 pub bare_curves: usize,
796 pub vertex_loops: usize,
799 pub max_pcurve_deviation: f64,
802 pub worst_omitted_deviation: f64,
813 pub pmi_unresolved_references: usize,
820 pub products: usize,
823 pub occurrences: usize,
826 pub styled_items: usize,
831}
832
833pub fn export_step(
842 solids: &[BrepSolid],
843 name: &str,
844 unit: &str,
845 timestamp: &str,
846) -> Result<String, String> {
847 export_step_report(solids, name, unit, timestamp).map(|report| report.text)
848}
849
850pub fn export_step_report(
853 solids: &[BrepSolid],
854 name: &str,
855 unit: &str,
856 timestamp: &str,
857) -> Result<StepExportReport, String> {
858 let named: Vec<(String, &BrepSolid)> = solids
859 .iter()
860 .map(|solid| (name.to_string(), solid))
861 .collect();
862 export_step_report_named(&named, name, unit, timestamp, None, None)
863}
864
865#[derive(Clone, Copy)]
874pub(crate) struct StepItemOwner {
875 pub product_shape: usize,
876 pub representation: usize,
877}
878
879#[derive(Default)]
883pub(crate) struct ProductGeometry {
884 pub solids: Vec<(String, usize)>,
889 pub faces: Vec<(String, usize)>,
891 pub edges: Vec<(String, usize)>,
893 pub vertices: Vec<(String, Vec<(Vec3, usize)>)>,
895}
896
897#[derive(Default)]
902pub(crate) struct StepNameMaps {
903 pub faces: HashMap<String, (usize, StepItemOwner)>,
904 pub edges: HashMap<String, (usize, StepItemOwner)>,
905 pub vertices: HashMap<String, (StepItemOwner, Vec<(Vec3, usize)>)>,
908}
909
910impl StepNameMaps {
911 pub(crate) fn register(
915 &mut self,
916 geometry: &ProductGeometry,
917 owner: StepItemOwner,
918 prefix: &str,
919 world: &Mat4,
920 ) {
921 for (name, id) in &geometry.faces {
922 self.faces
923 .entry(format!("{prefix}{name}"))
924 .or_insert((*id, owner));
925 }
926 for (name, id) in &geometry.edges {
927 self.edges
928 .entry(format!("{prefix}{name}"))
929 .or_insert((*id, owner));
930 }
931 for (name, points) in &geometry.vertices {
932 let placed = points
933 .iter()
934 .map(|(point, id)| (transform_point(world, *point), *id))
935 .collect();
936 self.vertices
937 .entry(format!("{prefix}{name}"))
938 .or_insert((owner, placed));
939 }
940 }
941}
942
943pub(crate) struct StepFileContexts {
946 pub product_context: usize,
947 pub definition_context: usize,
948 pub geometry_context: usize,
949 pub parametric_context: usize,
950 pub length_unit: usize,
951 pub angle_unit: usize,
952 pub axis: usize,
956}
957
958pub(crate) fn write_file_contexts(
960 writer: &mut StepWriter,
961 unit: &str,
962) -> Result<StepFileContexts, String> {
963 let application = writer.add("APPLICATION_CONTEXT('managed model based 3d engineering')");
964 writer.add(format!(
965 "APPLICATION_PROTOCOL_DEFINITION('international standard','ap242_managed_model_based_3d_engineering',2014,#{application})"
966 ));
967 let product_context = writer.add(format!("PRODUCT_CONTEXT('',#{application},'mechanical')"));
968 let definition_context = writer.add(format!(
969 "PRODUCT_DEFINITION_CONTEXT('part definition',#{application},'design')"
970 ));
971 let length_unit = write_length_unit(writer, unit)?;
972 let angle_unit = writer.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))");
973 let solid_angle_unit = writer.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())");
974 let uncertainty = writer.add(format!(
975 "UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{length_unit},'distance_accuracy_value','')"
976 ));
977 let geometry_context = writer.add(format!(
978 "(GEOMETRIC_REPRESENTATION_CONTEXT(3)\
979 GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{uncertainty}))\
980 GLOBAL_UNIT_ASSIGNED_CONTEXT((#{length_unit},#{angle_unit},#{solid_angle_unit}))\
981 REPRESENTATION_CONTEXT('',''))"
982 ));
983 let parametric_context = writer.add(
988 "(GEOMETRIC_REPRESENTATION_CONTEXT(2)\
989 PARAMETRIC_REPRESENTATION_CONTEXT()\
990 REPRESENTATION_CONTEXT('2D SPACE',''))",
991 );
992 let origin = write_point(writer, Vec3::default())?;
993 let direction_z = writer.add("DIRECTION('',(0.,0.,1.))");
994 let direction_x = writer.add("DIRECTION('',(1.,0.,0.))");
995 let axis = writer.add(format!(
996 "AXIS2_PLACEMENT_3D('',#{origin},#{direction_z},#{direction_x})"
997 ));
998 Ok(StepFileContexts {
999 product_context,
1000 definition_context,
1001 geometry_context,
1002 parametric_context,
1003 length_unit,
1004 angle_unit,
1005 axis,
1006 })
1007}
1008
1009pub(crate) struct ProductIds {
1013 pub definition: usize,
1014 pub product_shape: usize,
1015}
1016
1017pub(crate) fn write_product(
1023 writer: &mut StepWriter,
1024 contexts: &StepFileContexts,
1025 name: &str,
1026 id: &str,
1027 category: &str,
1028) -> ProductIds {
1029 let safe_name = step_string(name);
1030 let safe_id = step_string(if id.is_empty() { name } else { id });
1031 let product_context = contexts.product_context;
1032 let definition_context = contexts.definition_context;
1033 let product = writer.add(format!(
1034 "PRODUCT('{safe_id}','{safe_name}','',(#{product_context}))"
1035 ));
1036 writer.add(format!(
1037 "PRODUCT_RELATED_PRODUCT_CATEGORY('{}','',(#{product}))",
1038 step_string(category)
1039 ));
1040 let formation = writer.add(format!("PRODUCT_DEFINITION_FORMATION('','',#{product})"));
1041 let definition = writer.add(format!(
1042 "PRODUCT_DEFINITION('design','',#{formation},#{definition_context})"
1043 ));
1044 let product_shape = writer.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{definition})"));
1045 ProductIds {
1046 definition,
1047 product_shape,
1048 }
1049}
1050
1051pub(crate) fn write_product_geometry(
1059 writer: &mut StepWriter,
1060 contexts: &StepFileContexts,
1061 bodies: &[(String, &BrepSolid)],
1062 report: &mut StepExportReport,
1063) -> Result<ProductGeometry, String> {
1064 for (_, solid) in bodies {
1065 let policy = KernelTolerances::for_solid(solid, 1e-7);
1066 let issues = solid.validate_with_tolerances(&KernelTolerances {
1067 pcurve_consistency: policy.export_knit,
1068 ..policy
1069 });
1070 if !issues.is_empty() {
1071 return Err(format!("export_step: invalid solid: {issues:?}"));
1072 }
1073 }
1074 let parametric_context = contexts.parametric_context;
1075 let mut written = ProductGeometry::default();
1076 for (solid_name, solid) in bodies {
1077 let solid = *solid;
1078 let solid_name = solid_name.as_str();
1079 let band = KernelTolerances::for_solid(solid, 1e-7).export_knit;
1080 let mut vertex_ids = HashMap::<u64, usize>::default();
1081 let mut edge_ids = HashMap::<u64, usize>::default();
1082 let mut surfaces = HashMap::<usize, (usize, bool, EmittedSurface)>::default();
1083 let mut emitted_shells = Vec::new();
1084 for shell in &solid.shells {
1085 for face in &shell.faces {
1090 let key = surface_key(face);
1091 if surfaces.contains_key(&key) {
1092 continue;
1093 }
1094 let entry = match write_analytic_surface(writer, &face.surface)? {
1095 Some(triple) => triple,
1096 None => (
1097 write_surface(writer, &face.surface)?,
1098 false,
1099 EmittedSurface::Spline,
1100 ),
1101 };
1102 surfaces.insert(key, entry);
1103 }
1104
1105 let mut edge_uses = HashMap::<u64, Vec<CoedgeUse>>::default();
1108 let mut edge_order: Vec<u64> = Vec::new();
1109 for face in &shell.faces {
1110 for loop_record in &face.loops {
1111 for coedge in &loop_record.coedges {
1112 let edge = edge_for(solid, coedge.edge_id)?;
1113 if edge.degenerate {
1114 continue;
1115 }
1116 let uses = edge_uses.entry(edge.id).or_default();
1117 if uses.is_empty() {
1118 edge_order.push(edge.id);
1119 }
1120 uses.push(CoedgeUse {
1121 surface_key: surface_key(face),
1122 face,
1123 coedge,
1124 });
1125 }
1126 }
1127 }
1128
1129 for edge_id in &edge_order {
1131 if edge_ids.contains_key(edge_id) {
1132 continue;
1133 }
1134 let edge = edge_for(solid, *edge_id)?;
1135 let subcurve = edge_subcurve(edge)?;
1136 let (curve, emitted_curve) = match write_analytic_curve(writer, &subcurve)? {
1137 Some(pair) => pair,
1138 None => (
1139 write_curve(writer, &subcurve)?,
1140 EmittedCurve::Spline { curve: subcurve },
1141 ),
1142 };
1143 let uses = &edge_uses[edge_id];
1144 let seam = uses.len() == 2 && uses[0].surface_key == uses[1].surface_key;
1148 let mut pcurves: Vec<(usize, Pcurve2d)> = Vec::new();
1149 let mut omitted = 0usize;
1150 if uses.len() == 2 {
1151 let mut ordered: Vec<&CoedgeUse> = uses.iter().collect();
1155 if seam && !ordered[0].coedge.forward {
1156 ordered.swap(0, 1);
1157 }
1158 for coedge_use in ordered {
1159 let oriented = if coedge_use.coedge.forward {
1164 coedge_use.coedge.pcurve.clone()
1165 } else {
1166 coedge_use.coedge.pcurve.reversed()?
1167 };
1168 let (surface_id, _, emitted_surface) = &surfaces[&coedge_use.surface_key];
1169 let outcome = build_pcurve(
1170 &coedge_use.face.surface,
1171 emitted_surface,
1172 &emitted_curve,
1173 &oriented,
1174 band,
1175 )?;
1176 match outcome.curve {
1177 Some(curve_2d) => {
1178 report.max_pcurve_deviation =
1179 report.max_pcurve_deviation.max(outcome.deviation);
1180 pcurves.push((*surface_id, curve_2d));
1181 }
1182 None => {
1183 omitted += 1;
1184 if outcome.deviation.is_finite() {
1185 report.worst_omitted_deviation =
1186 report.worst_omitted_deviation.max(outcome.deviation);
1187 } else {
1188 report.worst_omitted_deviation = f64::INFINITY;
1189 }
1190 }
1191 }
1192 }
1193 }
1194 if seam && pcurves.len() != 2 {
1198 omitted += pcurves.len();
1199 pcurves.clear();
1200 }
1201 report.pcurves_omitted += omitted;
1202 report.pcurves_written += pcurves.len();
1203 let geometry = if pcurves.is_empty() {
1204 report.bare_curves += 1;
1205 curve
1206 } else {
1207 let ids = pcurves
1208 .iter()
1209 .map(|(surface_id, curve_2d)| {
1210 write_pcurve_entity(
1211 writer,
1212 *surface_id,
1213 parametric_context,
1214 curve_2d,
1215 )
1216 })
1217 .collect::<Result<Vec<_>, String>>()?;
1218 let keyword = if seam {
1219 report.seam_curves += 1;
1220 "SEAM_CURVE"
1221 } else {
1222 report.surface_curves += 1;
1223 "SURFACE_CURVE"
1224 };
1225 writer.add(format!(
1231 "{keyword}('',#{curve},{},.CURVE_3D.)",
1232 id_list(&ids)
1233 ))
1234 };
1235 let start = vertex_step_id(writer, &mut vertex_ids, solid, edge.start_vertex_id)?;
1236 let end = vertex_step_id(writer, &mut vertex_ids, solid, edge.end_vertex_id)?;
1237 let step_id =
1238 writer.add(format!("EDGE_CURVE('',#{start},#{end},#{geometry},.T.)"));
1239 edge_ids.insert(edge.id, step_id);
1240 if let Some(edge_name) = edge.name.as_deref() {
1241 written.edges.push((edge_name.to_string(), step_id));
1242 }
1243 }
1244
1245 let mut face_ids = Vec::new();
1247 for face in &shell.faces {
1248 let mut bound_ids = Vec::new();
1249 for (loop_index, loop_record) in face.loops.iter().enumerate() {
1250 let mut oriented_edges = Vec::new();
1251 for coedge in &loop_record.coedges {
1252 let edge = edge_for(solid, coedge.edge_id)?;
1253 if edge.degenerate {
1254 continue;
1255 }
1256 let edge_id = *edge_ids
1257 .get(&edge.id)
1258 .ok_or_else(|| format!("export_step: unwritten edge {}", edge.id))?;
1259 let orientation = if coedge.forward { ".T." } else { ".F." };
1260 oriented_edges.push(
1261 writer.add(format!("ORIENTED_EDGE('',*,*,#{edge_id},{orientation})")),
1262 );
1263 }
1264 let kind = if loop_index == 0 {
1265 "FACE_OUTER_BOUND"
1266 } else {
1267 "FACE_BOUND"
1268 };
1269 if oriented_edges.is_empty() {
1270 let Some(coedge) = loop_record.coedges.first() else {
1283 continue;
1284 };
1285 let collapsed = edge_for(solid, coedge.edge_id)?;
1286 let vertex = vertex_step_id(
1287 writer,
1288 &mut vertex_ids,
1289 solid,
1290 collapsed.start_vertex_id,
1291 )?;
1292 let vertex_loop = writer.add(format!("VERTEX_LOOP('',#{vertex})"));
1293 report.vertex_loops += 1;
1294 bound_ids.push(writer.add(format!("{kind}('',#{vertex_loop},.T.)")));
1295 continue;
1296 }
1297 let edge_loop =
1298 writer.add(format!("EDGE_LOOP('',{})", id_list(&oriented_edges)));
1299 bound_ids.push(writer.add(format!("{kind}('',#{edge_loop},.T.)")));
1300 }
1301 let (surface, flipped, _) = surfaces[&surface_key(face)];
1302 let sense = if face.same_sense != flipped {
1306 ".T."
1307 } else {
1308 ".F."
1309 };
1310 let face_step_id = writer.add(format!(
1311 "ADVANCED_FACE('',{},#{surface},{sense})",
1312 id_list(&bound_ids)
1313 ));
1314 if let Some(face_name) = face.name.as_deref() {
1315 written.faces.push((face_name.to_string(), face_step_id));
1316 }
1317 face_ids.push(face_step_id);
1318 }
1319 let closed_shell = writer.add(format!("CLOSED_SHELL('',{})", id_list(&face_ids)));
1320 let inward = if solid.shells.len() > 1 {
1321 crate::mass_properties::shell_signed_volume(shell)? < 0.0
1322 } else {
1323 false
1324 };
1325 emitted_shells.push((closed_shell, inward));
1326 }
1327 if emitted_shells.iter().any(|&(_, inward)| inward) {
1328 let outer = emitted_shells.iter().filter(|&&(_, inward)| !inward).collect::<Vec<_>>();
1329 if outer.len() != 1 {
1330 return Err("export_step: a solid with voids must have exactly one outward shell".into());
1331 }
1332 let voids = emitted_shells.iter().filter(|&&(_, inward)| inward)
1333 .map(|&(shell, _)| writer.add(format!("ORIENTED_CLOSED_SHELL('',*,#{shell},.T.)")))
1334 .collect::<Vec<_>>();
1335 let body_id = writer.add(format!(
1336 "BREP_WITH_VOIDS('{}',#{},{})", step_string(solid_name), outer[0].0, id_list(&voids)
1337 ));
1338 written.solids.push((solid_name.to_string(), body_id));
1339 } else {
1340 for (closed_shell, _) in emitted_shells {
1341 let body_id = writer.add(format!(
1342 "MANIFOLD_SOLID_BREP('{}',#{closed_shell})", step_string(solid_name)
1343 ));
1344 written.solids.push((solid_name.to_string(), body_id));
1345 }
1346 }
1347 let mut points: Vec<(Vec3, usize)> = Vec::with_capacity(vertex_ids.len());
1349 for (vertex_id, step_id) in &vertex_ids {
1350 points.push((vertex_for(solid, *vertex_id)?.point, *step_id));
1351 }
1352 written.vertices.push((solid_name.to_string(), points));
1353 }
1354 Ok(written)
1355}
1356
1357pub(crate) fn finish_step_file(
1360 writer: StepWriter,
1361 name: &str,
1362 timestamp: &str,
1363 report: &mut StepExportReport,
1364) -> Result<(), String> {
1365 let safe_name = step_string(name);
1366 let safe_timestamp = step_string(timestamp);
1367 let output = [
1368 "ISO-10303-21;".to_string(),
1369 "HEADER;".to_string(),
1370 "FILE_DESCRIPTION((''),'2;1');".to_string(),
1371 format!(
1372 "FILE_NAME('{safe_name}.step','{safe_timestamp}',(''),(''),'brep-kernel-rs','brep-kernel-rs','');"
1373 ),
1374 "FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 1 1 4 }'));".to_string(),
1375 "ENDSEC;".to_string(),
1376 "DATA;".to_string(),
1377 writer.data(),
1378 "ENDSEC;".to_string(),
1379 "END-ISO-10303-21;".to_string(),
1380 String::new(),
1381 ]
1382 .join("\n");
1383 let manifold_issues = audit_step_manifold(&output);
1384 if !manifold_issues.is_empty() {
1385 return Err(format!(
1386 "export_step: emitted AP242 manifold audit failed: {}",
1387 manifold_issues.join("; ")
1388 ));
1389 }
1390 let pcurve_issues = audit_step_pcurves(&output);
1391 if !pcurve_issues.is_empty() {
1392 return Err(format!(
1393 "export_step: emitted AP242 pcurve audit failed: {}",
1394 pcurve_issues.join("; ")
1395 ));
1396 }
1397 report.text = output;
1398 Ok(())
1399}
1400
1401pub fn export_step_report_named(
1410 solids: &[(String, &BrepSolid)],
1411 name: &str,
1412 unit: &str,
1413 timestamp: &str,
1414 pmi: Option<&StepPmi<'_>>,
1415 colors: Option<&StepColors>,
1416) -> Result<StepExportReport, String> {
1417 if solids.is_empty() {
1418 return Err("export_step: at least one solid is required".into());
1419 }
1420 let mut report = StepExportReport {
1421 products: 1,
1422 ..StepExportReport::default()
1423 };
1424 let mut writer = StepWriter::default();
1425 let contexts = write_file_contexts(&mut writer, unit)?;
1426 let geometry = write_product_geometry(&mut writer, &contexts, solids, &mut report)?;
1427 let mut items = vec![contexts.axis];
1428 items.extend(geometry.solids.iter().map(|(_, id)| *id));
1429 let geometry_context = contexts.geometry_context;
1430 let representation = writer.add(format!(
1431 "ADVANCED_BREP_SHAPE_REPRESENTATION('',{},#{geometry_context})",
1432 id_list(&items)
1433 ));
1434 let product = write_product(&mut writer, &contexts, name, "", "part");
1435 let product_shape = product.product_shape;
1436 writer.add(format!(
1437 "SHAPE_DEFINITION_REPRESENTATION(#{product_shape},#{representation})"
1438 ));
1439 if let Some(colors) = colors.filter(|colors| !colors.is_empty()) {
1444 let items = styles::styled_items(&geometry, colors, &[String::new()]);
1445 report.styled_items = styles::write_geometry_styles(&mut writer, geometry_context, &items)?;
1446 }
1447 if let Some(pmi) = pmi {
1448 let mut names = StepNameMaps::default();
1449 names.register(
1450 &geometry,
1451 StepItemOwner {
1452 product_shape,
1453 representation,
1454 },
1455 "",
1456 &MAT4_IDENTITY,
1457 );
1458 let context = pmi::StepContext {
1459 product_shape,
1460 representation,
1461 geometry_context,
1462 length_unit: contexts.length_unit,
1463 angle_unit: contexts.angle_unit,
1464 faces: &names.faces,
1465 edges: &names.edges,
1466 vertices: &names.vertices,
1467 };
1468 report.pmi_unresolved_references = pmi::write_pmi(&mut writer, &context, pmi)?;
1469 }
1470 finish_step_file(writer, name, timestamp, &mut report)?;
1471 Ok(report)
1472}
1473
1474fn vertex_step_id(
1476 writer: &mut StepWriter,
1477 vertex_ids: &mut HashMap<u64, usize>,
1478 solid: &BrepSolid,
1479 id: u64,
1480) -> Result<usize, String> {
1481 if let Some(step_id) = vertex_ids.get(&id) {
1482 return Ok(*step_id);
1483 }
1484 let point = write_point(writer, vertex_for(solid, id)?.point)?;
1485 let step_id = writer.add(format!("VERTEX_POINT('',#{point})"));
1486 vertex_ids.insert(id, step_id);
1487 Ok(step_id)
1488}
1489
1490fn step_entity_bodies(step: &str) -> HashMap<u64, &str> {
1493 step.lines()
1494 .filter_map(|line| {
1495 let rest = line.strip_prefix('#')?;
1496 let (digits, body) = rest.split_once('=')?;
1497 Some((
1498 digits.parse::<u64>().ok()?,
1499 body.trim_end().trim_end_matches(';'),
1500 ))
1501 })
1502 .collect()
1503}
1504
1505fn step_entity_refs(body: &str) -> Vec<u64> {
1509 let mut refs = Vec::new();
1510 let bytes = body.as_bytes();
1511 let mut index = 0;
1512 while index < bytes.len() {
1513 if bytes[index] == b'#' {
1514 let start = index + 1;
1515 let mut end = start;
1516 while end < bytes.len() && bytes[end].is_ascii_digit() {
1517 end += 1;
1518 }
1519 if end > start {
1520 if let Ok(id) = body[start..end].parse::<u64>() {
1521 refs.push(id);
1522 }
1523 }
1524 index = end;
1525 } else {
1526 index += 1;
1527 }
1528 }
1529 refs
1530}
1531
1532pub fn audit_step_pcurves(step: &str) -> Vec<String> {
1545 let bodies = step_entity_bodies(step);
1546 let mut issues = Vec::new();
1547 for (id, body) in &bodies {
1548 if !body.starts_with("EDGE_CURVE(") {
1549 continue;
1550 }
1551 let refs = step_entity_refs(body);
1552 let Some(geometry) = refs.get(2) else {
1553 issues.push(format!("EDGE_CURVE #{id} has no edge_geometry"));
1554 continue;
1555 };
1556 let Some(wrapper) = bodies.get(geometry) else {
1557 issues.push(format!("EDGE_CURVE #{id} references missing #{geometry}"));
1558 continue;
1559 };
1560 let seam = wrapper.starts_with("SEAM_CURVE(");
1561 if !seam && !wrapper.starts_with("SURFACE_CURVE(") {
1562 continue;
1563 }
1564 let wrapper_refs = step_entity_refs(wrapper);
1565 let pcurves = &wrapper_refs[wrapper_refs.len().min(1)..];
1566 if pcurves.is_empty() || pcurves.len() > 2 || (seam && pcurves.len() != 2) {
1567 issues.push(format!(
1568 "#{geometry} carries {} associated geometries",
1569 pcurves.len()
1570 ));
1571 continue;
1572 }
1573 let mut surfaces = Vec::new();
1574 for pcurve in pcurves {
1575 let Some(pcurve_body) = bodies.get(pcurve) else {
1576 issues.push(format!("#{geometry} references missing #{pcurve}"));
1577 continue;
1578 };
1579 if !pcurve_body.starts_with("PCURVE(") {
1580 issues.push(format!("#{geometry} associate #{pcurve} is not a PCURVE"));
1581 continue;
1582 }
1583 let pcurve_refs = step_entity_refs(pcurve_body);
1584 let representation = pcurve_refs.get(1).and_then(|id| bodies.get(id));
1585 if !representation.is_some_and(|body| body.starts_with("DEFINITIONAL_REPRESENTATION("))
1586 {
1587 issues.push(format!(
1588 "PCURVE #{pcurve} has no DEFINITIONAL_REPRESENTATION"
1589 ));
1590 }
1591 if let Some(surface) = pcurve_refs.first() {
1592 surfaces.push(*surface);
1593 }
1594 }
1595 if surfaces.len() == 2 && (surfaces[0] == surfaces[1]) != seam {
1596 issues.push(format!(
1597 "#{geometry} pcurves name {} surface(s) but it is a {}",
1598 if surfaces[0] == surfaces[1] { 1 } else { 2 },
1599 if seam { "SEAM_CURVE" } else { "SURFACE_CURVE" }
1600 ));
1601 }
1602 }
1603 issues.sort();
1604 issues
1605}
1606
1607pub fn audit_step_manifold(step: &str) -> Vec<String> {
1612 let marker = "ORIENTED_EDGE('',*,*,#";
1613 let mut uses = HashMap::<u64, Vec<bool>>::default();
1614 for line in step.lines() {
1615 let Some(offset) = line.find(marker) else {
1616 continue;
1617 };
1618 let rest = &line[offset + marker.len()..];
1619 let digits = rest
1620 .chars()
1621 .take_while(|character| character.is_ascii_digit())
1622 .collect::<String>();
1623 let Ok(edge_id) = digits.parse::<u64>() else {
1624 continue;
1625 };
1626 let suffix = &rest[digits.len()..];
1627 let sense = suffix.starts_with(",.T.");
1628 uses.entry(edge_id).or_default().push(sense);
1629 }
1630 let mut issues = uses
1631 .into_iter()
1632 .filter_map(|(edge, senses)| {
1633 (senses.len() != 2 || senses[0] == senses[1]).then(|| {
1634 format!(
1635 "EDGE_CURVE #{edge} has {} uses with senses {:?}",
1636 senses.len(),
1637 senses
1638 )
1639 })
1640 })
1641 .collect::<Vec<_>>();
1642 issues.sort();
1643 issues
1644}
1645