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
10fn step_string(value: &str) -> String {
11 value.replace('\'', "''")
12}
13
14fn real(value: f64) -> Result<String, String> {
15 if !value.is_finite() {
16 return Err(format!("export_step: non-finite number {value}"));
17 }
18 if value == 0.0 {
21 return Ok("0.".into());
22 }
23 if value.fract() == 0.0 && value.abs() < 1e15 {
24 return Ok(format!("{value:.0}."));
25 }
26 let mut output = format!("{value:.15}");
27 while output.ends_with('0') {
28 output.pop();
29 }
30 if output.ends_with('.') {
31 output.push('0');
32 }
33 if output == "-0.0" {
34 output = "0.0".into();
35 }
36 Ok(output)
37}
38
39fn knot_runs(knots: &[f64]) -> (Vec<f64>, Vec<usize>) {
40 let mut values = Vec::new();
41 let mut multiplicities = Vec::new();
42 for &knot in knots {
43 if values
44 .last()
45 .is_some_and(|previous: &f64| (*previous - knot).abs() <= 1e-12)
46 {
47 *multiplicities.last_mut().unwrap() += 1;
48 } else {
49 values.push(knot);
50 multiplicities.push(1);
51 }
52 }
53 (values, multiplicities)
54}
55
56pub(crate) fn edge_subcurve(edge: &EdgeRecord) -> Result<NurbsCurve, String> {
57 let [start, end] = edge.curve.domain()?;
58 let epsilon = (1e-9 * (end - start)).max(2e-9);
59 let mut curve = edge.curve.clone();
60 if edge.t0 > start + epsilon && edge.t0 < end - epsilon {
61 curve = curve.split(edge.t0)?.1;
62 }
63 let domain = curve.domain()?;
64 if edge.t1 < domain[1] - epsilon && edge.t1 > domain[0] + epsilon {
65 curve = curve.split(edge.t1)?.0;
66 }
67 Ok(curve)
68}
69
70#[derive(Default)]
71struct StepWriter {
72 lines: Vec<String>,
73}
74
75impl StepWriter {
76 fn add(&mut self, body: impl Into<String>) -> usize {
77 let id = self.lines.len() + 1;
78 self.lines.push(format!("#{id}={};", body.into()));
79 id
80 }
81
82 fn data(&self) -> String {
83 self.lines.join("\n")
84 }
85}
86
87fn write_point(writer: &mut StepWriter, point: Vec3) -> Result<usize, String> {
88 Ok(writer.add(format!(
89 "CARTESIAN_POINT('',({},{},{}))",
90 real(point.x)?,
91 real(point.y)?,
92 real(point.z)?
93 )))
94}
95
96fn id_list(ids: &[usize]) -> String {
97 format!(
98 "({})",
99 ids.iter()
100 .map(|id| format!("#{id}"))
101 .collect::<Vec<_>>()
102 .join(",")
103 )
104}
105
106fn write_direction(writer: &mut StepWriter, direction: Vec3) -> Result<usize, String> {
107 Ok(writer.add(format!(
108 "DIRECTION('',({},{},{}))",
109 real(direction.x)?,
110 real(direction.y)?,
111 real(direction.z)?
112 )))
113}
114
115fn write_placement(
116 writer: &mut StepWriter,
117 origin: Vec3,
118 axis: Vec3,
119 ref_direction: Vec3,
120) -> Result<usize, String> {
121 let origin = write_point(writer, origin)?;
122 let axis = write_direction(writer, axis)?;
123 let ref_direction = write_direction(writer, ref_direction)?;
124 Ok(writer.add(format!(
125 "AXIS2_PLACEMENT_3D('',#{origin},#{axis},#{ref_direction})"
126 )))
127}
128
129fn write_analytic_surface(
138 writer: &mut StepWriter,
139 surface: &NurbsSurface,
140) -> Result<Option<(usize, bool, EmittedSurface)>, String> {
141 let Some(analytic) = surface.analytic() else {
142 return Ok(None);
143 };
144 match analytic {
145 AnalyticSurface::Plane {
146 origin,
147 u_dir,
148 v_dir,
149 ..
150 } => {
151 let (Ok(normal), Ok(x_axis)) = (u_dir.cross(*v_dir).normalized(), u_dir.normalized())
154 else {
155 return Ok(None);
156 };
157 let placement = write_placement(writer, *origin, normal, x_axis)?;
158 Ok(Some((
163 writer.add(format!("PLANE('',#{placement})")),
164 false,
165 EmittedSurface::Plane {
166 origin: *origin,
167 x_axis,
168 y_axis: normal.cross(x_axis),
169 },
170 )))
171 }
172 AnalyticSurface::RuledRevolution {
173 frame,
174 rho0,
175 rho1,
176 height,
177 } => {
178 let flipped = *height < 0.0;
182 let radius_scale = 1.0 + rho0.abs().max(rho1.abs());
183 if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
184 if *rho0 <= 0.0 {
185 return Ok(None);
186 }
187 let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
188 return Ok(Some((
189 writer.add(format!(
190 "CYLINDRICAL_SURFACE('',#{placement},{})",
191 real(*rho0)?
192 )),
193 flipped,
194 EmittedSurface::Cylinder {
195 frame: EmittedFrame {
196 origin: frame.origin,
197 x_axis: frame.x_axis,
198 y_axis: frame.y_axis,
199 axis: frame.axis,
200 azimuth_sign: 1.0,
201 },
202 radius: *rho0,
203 },
204 )));
205 }
206 let slope = (rho1 - rho0) / height;
212 let apex_margin = 2.0 * (1e-4 * height.abs().max(1.0) + 1e-9) * slope.abs();
213 if rho0.min(*rho1) <= apex_margin {
214 return Ok(None);
215 }
216 let axis = if slope >= 0.0 {
220 frame.axis
221 } else {
222 frame.axis.scale(-1.0)
223 };
224 let placement = write_placement(writer, frame.origin, axis, frame.x_axis)?;
225 Ok(Some((
226 writer.add(format!(
227 "CONICAL_SURFACE('',#{placement},{},{})",
228 real(*rho0)?,
229 real(slope.abs().atan())?
230 )),
231 flipped,
232 EmittedSurface::Cone {
233 frame: EmittedFrame {
234 origin: frame.origin,
235 x_axis: frame.x_axis,
236 y_axis: axis.cross(frame.x_axis),
240 axis,
241 azimuth_sign: if slope >= 0.0 { 1.0 } else { -1.0 },
242 },
243 radius: *rho0,
244 semi_angle: slope.abs().atan(),
245 },
246 )))
247 }
248 AnalyticSurface::Sphere { frame, radius } => {
249 let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
252 Ok(Some((
253 writer.add(format!(
254 "SPHERICAL_SURFACE('',#{placement},{})",
255 real(*radius)?
256 )),
257 false,
258 EmittedSurface::Sphere {
259 frame: EmittedFrame {
260 origin: frame.origin,
261 x_axis: frame.x_axis,
262 y_axis: frame.y_axis,
263 axis: frame.axis,
264 azimuth_sign: 1.0,
265 },
266 radius: *radius,
267 },
268 )))
269 }
270 AnalyticSurface::Torus {
271 frame,
272 major_radius,
273 minor_radius,
274 } => {
275 let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
276 Ok(Some((
277 writer.add(format!(
278 "TOROIDAL_SURFACE('',#{placement},{},{})",
279 real(*major_radius)?,
280 real(*minor_radius)?
281 )),
282 false,
283 EmittedSurface::Torus {
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 major_radius: *major_radius,
292 minor_radius: *minor_radius,
293 },
294 )))
295 }
296 AnalyticSurface::Revolution { .. } => Ok(None),
299 }
300}
301
302struct CircularArc {
307 center: Vec3,
308 axis: Vec3,
309 x_axis: Vec3,
310 y_axis: Vec3,
313 radius: f64,
314 sweep: f64,
316 spans: usize,
320}
321
322fn curve_scale(curve: &NurbsCurve) -> f64 {
323 curve
324 .control_points
325 .iter()
326 .map(|p| (p.x.abs() / p.w).max(p.y.abs() / p.w).max(p.z.abs() / p.w))
327 .fold(0.0, f64::max)
328}
329
330fn curves_match(a: &NurbsCurve, b: &NurbsCurve, scale: f64) -> bool {
334 if a.degree != b.degree
335 || a.knots.len() != b.knots.len()
336 || a.control_points.len() != b.control_points.len()
337 {
338 return false;
339 }
340 if a.knots
341 .iter()
342 .zip(&b.knots)
343 .any(|(x, y)| (x - y).abs() > 1e-12)
344 {
345 return false;
346 }
347 let tolerance = 1e-9 * scale.max(1.0);
348 a.control_points
349 .iter()
350 .zip(&b.control_points)
351 .all(|(p, q)| {
352 (p.x - q.x).abs() <= tolerance
353 && (p.y - q.y).abs() <= tolerance
354 && (p.z - q.z).abs() <= tolerance
355 && (p.w - q.w).abs() <= 1e-9
356 })
357}
358
359fn recognize_circular_arc(curve: &NurbsCurve) -> Option<CircularArc> {
364 if curve.degree != 2
365 || curve.control_points.len() < 3
366 || curve.control_points.len() % 2 == 0
367 || (curve.control_points.len() - 1) / 2 > 4
368 {
369 return None;
370 }
371 let [t0, t1] = curve.domain().ok()?;
372 let at = |fraction: f64| curve.evaluate(t0 + (t1 - t0) * fraction);
373 let p0 = at(0.0).ok()?;
376 let pa = at(0.35).ok()?;
377 let pb = at(0.7).ok()?;
378 let center = circumcenter(p0, pa, pb)?;
379 let radial = p0.sub(center);
380 let radius = radial.length();
381 let scale = curve_scale(curve);
382 if radius <= 1e-9 * scale.max(1.0) {
383 return None;
384 }
385 let x_axis = radial.scale(1.0 / radius);
386 let axis = radial.cross(pa.sub(center)).normalized().ok()?;
387 let y_axis = axis.cross(x_axis);
388 let p_end = at(1.0).ok()?;
389 let sweep = if p_end.sub(p0).length() <= 1e-9 * (1.0 + radius) {
390 std::f64::consts::TAU
391 } else {
392 let closing = p_end.sub(center);
393 let mut angle = closing.dot(y_axis).atan2(closing.dot(x_axis));
394 if angle < 0.0 {
395 angle += std::f64::consts::TAU;
396 }
397 angle
398 };
399 let rebuilt = make_arc(center, x_axis, y_axis, radius, 0.0, sweep).ok()?;
400 curves_match(curve, &rebuilt, scale).then_some(CircularArc {
401 center,
402 axis,
403 x_axis,
404 y_axis,
405 radius,
406 sweep,
407 spans: (curve.control_points.len() - 1) / 2,
408 })
409}
410
411fn write_analytic_curve(
418 writer: &mut StepWriter,
419 curve: &NurbsCurve,
420) -> Result<Option<(usize, EmittedCurve)>, String> {
421 if curve.degree == 1
422 && curve.control_points.len() == 2
423 && curve
424 .control_points
425 .iter()
426 .all(|control| (control.w - 1.0).abs() <= 1e-12)
427 {
428 let start = curve.control_points[0].point()?;
429 let end = curve.control_points[1].point()?;
430 let Ok(direction) = end.sub(start).normalized() else {
431 return Ok(None);
432 };
433 let point = write_point(writer, start)?;
434 let step_direction = write_direction(writer, direction)?;
435 let vector = writer.add(format!(
436 "VECTOR('',#{step_direction},{})",
437 real(end.sub(start).length())?
438 ));
439 return Ok(Some((
440 writer.add(format!("LINE('',#{point},#{vector})")),
441 EmittedCurve::Line { start, end },
445 )));
446 }
447 if let Some(arc) = recognize_circular_arc(curve) {
448 let placement = write_placement(writer, arc.center, arc.axis, arc.x_axis)?;
452 return Ok(Some((
453 writer.add(format!("CIRCLE('',#{placement},{})", real(arc.radius)?)),
454 EmittedCurve::Circle {
455 center: arc.center,
456 x_axis: arc.x_axis,
457 y_axis: arc.y_axis,
458 radius: arc.radius,
459 sweep: arc.sweep,
460 spans: arc.spans,
461 },
462 )));
463 }
464 Ok(None)
465}
466
467fn write_curve(writer: &mut StepWriter, curve: &NurbsCurve) -> Result<usize, String> {
468 let points = curve
469 .control_points
470 .iter()
471 .map(|control| write_point(writer, control.point()?))
472 .collect::<Result<Vec<_>, _>>()?;
473 write_bspline_curve(writer, curve, &points)
474}
475
476fn write_bspline_curve(
481 writer: &mut StepWriter,
482 curve: &NurbsCurve,
483 points: &[usize],
484) -> Result<usize, String> {
485 let (knot_values, multiplicities) = knot_runs(&curve.knots);
486 let multiplicities = format!(
487 "({})",
488 multiplicities
489 .iter()
490 .map(usize::to_string)
491 .collect::<Vec<_>>()
492 .join(",")
493 );
494 let knots = format!(
495 "({})",
496 knot_values
497 .iter()
498 .map(|value| real(*value))
499 .collect::<Result<Vec<_>, _>>()?
500 .join(",")
501 );
502 let rational = curve
503 .control_points
504 .iter()
505 .any(|control| (control.w - 1.0).abs() > 1e-12);
506 if !rational {
507 return Ok(writer.add(format!(
508 "B_SPLINE_CURVE_WITH_KNOTS('',{},{},.UNSPECIFIED.,.F.,.F.,{multiplicities},{knots},.UNSPECIFIED.)",
509 curve.degree,
510 id_list(points),
511 )));
512 }
513 let weights = format!(
514 "({})",
515 curve
516 .control_points
517 .iter()
518 .map(|control| real(control.w))
519 .collect::<Result<Vec<_>, _>>()?
520 .join(",")
521 );
522 Ok(writer.add(format!(
523 "(BOUNDED_CURVE()B_SPLINE_CURVE({},{},.UNSPECIFIED.,.F.,.F.)\
524 B_SPLINE_CURVE_WITH_KNOTS({multiplicities},{knots},.UNSPECIFIED.)\
525 CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE({weights})\
526 REPRESENTATION_ITEM(''))",
527 curve.degree,
528 id_list(points),
529 )))
530}
531
532fn write_point_2d(writer: &mut StepWriter, point: [f64; 2]) -> Result<usize, String> {
533 Ok(writer.add(format!(
534 "CARTESIAN_POINT('',({},{}))",
535 real(point[0])?,
536 real(point[1])?
537 )))
538}
539
540fn write_direction_2d(writer: &mut StepWriter, direction: [f64; 2]) -> Result<usize, String> {
541 Ok(writer.add(format!(
542 "DIRECTION('',({},{}))",
543 real(direction[0])?,
544 real(direction[1])?
545 )))
546}
547
548fn write_pcurve_geometry(writer: &mut StepWriter, curve: &Pcurve2d) -> Result<usize, String> {
558 match curve {
559 Pcurve2d::Line { point, vector } => {
560 let magnitude = vector[0].hypot(vector[1]);
561 if magnitude <= 0.0 {
562 return Err("export_step: degenerate 2D line pcurve".into());
563 }
564 let point_id = write_point_2d(writer, *point)?;
565 let direction =
566 write_direction_2d(writer, [vector[0] / magnitude, vector[1] / magnitude])?;
567 let vector_id = writer.add(format!(
568 "VECTOR('',#{direction},{})",
569 real(magnitude)?
570 ));
571 Ok(writer.add(format!("LINE('',#{point_id},#{vector_id})")))
572 }
573 Pcurve2d::Circle {
574 center,
575 ref_direction,
576 radius,
577 } => {
578 let center_id = write_point_2d(writer, *center)?;
579 let direction = write_direction_2d(writer, *ref_direction)?;
580 let placement = writer.add(format!(
581 "AXIS2_PLACEMENT_2D('',#{center_id},#{direction})"
582 ));
583 Ok(writer.add(format!("CIRCLE('',#{placement},{})", real(*radius)?)))
584 }
585 Pcurve2d::Spline(spline) => {
586 let points = spline
587 .control_points
588 .iter()
589 .map(|control| {
590 let point = control.point()?;
591 write_point_2d(writer, [point.x, point.y])
592 })
593 .collect::<Result<Vec<_>, String>>()?;
594 write_bspline_curve(writer, spline, &points)
595 }
596 }
597}
598
599fn write_pcurve_entity(
602 writer: &mut StepWriter,
603 surface_id: usize,
604 context_2d: usize,
605 curve: &Pcurve2d,
606) -> Result<usize, String> {
607 let geometry = write_pcurve_geometry(writer, curve)?;
608 let representation = writer.add(format!(
609 "DEFINITIONAL_REPRESENTATION('',(#{geometry}),#{context_2d})"
610 ));
611 Ok(writer.add(format!("PCURVE('',#{surface_id},#{representation})")))
612}
613
614fn write_surface(writer: &mut StepWriter, surface: &NurbsSurface) -> Result<usize, String> {
615 let rows = surface
616 .control_points
617 .iter()
618 .map(|row| {
619 row.iter()
620 .map(|control| write_point(writer, control.point()?))
621 .collect::<Result<Vec<_>, _>>()
622 .map(|ids| id_list(&ids))
623 })
624 .collect::<Result<Vec<_>, _>>()?;
625 let grid = format!("({})", rows.join(","));
626 let (u_values, u_multiplicities) = knot_runs(&surface.knots_u);
627 let (v_values, v_multiplicities) = knot_runs(&surface.knots_v);
628 let multiplicities = |values: &[usize]| {
629 format!(
630 "({})",
631 values
632 .iter()
633 .map(usize::to_string)
634 .collect::<Vec<_>>()
635 .join(",")
636 )
637 };
638 let knots = |values: &[f64]| -> Result<String, String> {
639 Ok(format!(
640 "({})",
641 values
642 .iter()
643 .map(|value| real(*value))
644 .collect::<Result<Vec<_>, _>>()?
645 .join(",")
646 ))
647 };
648 let u_mults = multiplicities(&u_multiplicities);
649 let v_mults = multiplicities(&v_multiplicities);
650 let u_knots = knots(&u_values)?;
651 let v_knots = knots(&v_values)?;
652 let rational = surface
653 .control_points
654 .iter()
655 .flatten()
656 .any(|control| (control.w - 1.0).abs() > 1e-12);
657 if !rational {
658 return Ok(writer.add(format!(
659 "B_SPLINE_SURFACE_WITH_KNOTS('',{},{},{grid},.UNSPECIFIED.,.F.,.F.,.F.,\
660 {u_mults},{v_mults},{u_knots},{v_knots},.UNSPECIFIED.)",
661 surface.degree_u, surface.degree_v,
662 )));
663 }
664 let weights = format!(
665 "({})",
666 surface
667 .control_points
668 .iter()
669 .map(|row| {
670 row.iter()
671 .map(|control| real(control.w))
672 .collect::<Result<Vec<_>, _>>()
673 .map(|values| format!("({})", values.join(",")))
674 })
675 .collect::<Result<Vec<_>, _>>()?
676 .join(",")
677 );
678 Ok(writer.add(format!(
679 "(BOUNDED_SURFACE()B_SPLINE_SURFACE({},{},{grid},.UNSPECIFIED.,.F.,.F.,.F.)\
680 B_SPLINE_SURFACE_WITH_KNOTS({u_mults},{v_mults},{u_knots},{v_knots},.UNSPECIFIED.)\
681 GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE({weights})\
682 REPRESENTATION_ITEM('')SURFACE())",
683 surface.degree_u, surface.degree_v,
684 )))
685}
686
687fn write_length_unit(writer: &mut StepWriter, unit: &str) -> Result<usize, String> {
688 let normalized = unit.to_lowercase();
689 if normalized == "meter" || normalized == "metre" {
690 return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.))"));
691 }
692 if normalized == "centimeter" || normalized == "centimetre" {
693 return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))"));
694 }
695 if matches!(normalized.as_str(), "micron" | "micrometer" | "micrometre") {
696 return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MICRO.,.METRE.))"));
697 }
698 if normalized == "inch" || normalized == "foot" {
699 let metre = writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.))");
700 let (factor, name) = if normalized == "inch" {
701 (0.0254, "INCH")
702 } else {
703 (0.3048, "FOOT")
704 };
705 let measure = writer.add(format!(
706 "LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{metre})",
707 real(factor)?
708 ));
709 return Ok(writer.add(format!(
710 "(CONVERSION_BASED_UNIT('{name}',#{measure})LENGTH_UNIT()NAMED_UNIT(*))"
711 )));
712 }
713 Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))"))
714}
715
716fn vertex_for(solid: &BrepSolid, id: u64) -> Result<&VertexRecord, String> {
717 solid
718 .vertices
719 .iter()
720 .find(|vertex| vertex.id == id)
721 .ok_or_else(|| format!("export_step: missing vertex {id}"))
722}
723
724fn edge_for(solid: &BrepSolid, id: u64) -> Result<&EdgeRecord, String> {
725 solid
726 .edges
727 .iter()
728 .find(|edge| edge.id == id)
729 .ok_or_else(|| format!("export_step: missing edge {id}"))
730}
731
732fn surface_key(face: &FaceRecord) -> usize {
733 face as *const FaceRecord as usize
734}
735
736struct CoedgeUse<'a> {
740 surface_key: usize,
741 face: &'a FaceRecord,
742 coedge: &'a CoedgeRecord,
743}
744
745#[derive(Clone, Debug, Default)]
754pub struct StepExportReport {
755 pub text: String,
757 pub pcurves_written: usize,
760 pub pcurves_omitted: usize,
763 pub surface_curves: usize,
765 pub seam_curves: usize,
768 pub bare_curves: usize,
771 pub vertex_loops: usize,
774 pub max_pcurve_deviation: f64,
777 pub worst_omitted_deviation: f64,
788}
789
790pub fn export_step(
792 solids: &[BrepSolid],
793 name: &str,
794 unit: &str,
795 timestamp: &str,
796) -> Result<String, String> {
797 export_step_report(solids, name, unit, timestamp).map(|report| report.text)
798}
799
800pub fn export_step_report(
802 solids: &[BrepSolid],
803 name: &str,
804 unit: &str,
805 timestamp: &str,
806) -> Result<StepExportReport, String> {
807 if solids.is_empty() {
808 return Err("export_step: at least one solid is required".into());
809 }
810 for solid in solids {
811 let policy = KernelTolerances::for_solid(solid, 1e-7);
812 let issues = solid.validate_with_tolerances(&KernelTolerances {
813 pcurve_consistency: policy.export_knit,
814 ..policy
815 });
816 if !issues.is_empty() {
817 return Err(format!("export_step: invalid solid: {issues:?}"));
818 }
819 }
820 let mut report = StepExportReport::default();
821 let mut writer = StepWriter::default();
822 let safe_name = step_string(name);
823 let application = writer.add("APPLICATION_CONTEXT('automotive design')");
824 writer.add(format!(
825 "APPLICATION_PROTOCOL_DEFINITION('','automotive_design',2010,#{application})"
826 ));
827 let product_context = writer.add(format!("PRODUCT_CONTEXT('',#{application},'mechanical')"));
828 let product = writer.add(format!(
829 "PRODUCT('{safe_name}','{safe_name}','',(#{product_context}))"
830 ));
831 let formation = writer.add(format!("PRODUCT_DEFINITION_FORMATION('','',#{product})"));
832 let definition_context = writer.add(format!(
833 "PRODUCT_DEFINITION_CONTEXT('part definition',#{application},'design')"
834 ));
835 let definition = writer.add(format!(
836 "PRODUCT_DEFINITION('design','',#{formation},#{definition_context})"
837 ));
838 let product_shape = writer.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{definition})"));
839 let length_unit = write_length_unit(&mut writer, unit)?;
840 let angle_unit = writer.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))");
841 let solid_angle_unit = writer.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())");
842 let uncertainty = writer.add(format!(
843 "UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{length_unit},'distance_accuracy_value','')"
844 ));
845 let geometry_context = writer.add(format!(
846 "(GEOMETRIC_REPRESENTATION_CONTEXT(3)\
847 GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{uncertainty}))\
848 GLOBAL_UNIT_ASSIGNED_CONTEXT((#{length_unit},#{angle_unit},#{solid_angle_unit}))\
849 REPRESENTATION_CONTEXT('',''))"
850 ));
851 let parametric_context = writer.add(
856 "(GEOMETRIC_REPRESENTATION_CONTEXT(2)\
857 PARAMETRIC_REPRESENTATION_CONTEXT()\
858 REPRESENTATION_CONTEXT('2D SPACE',''))",
859 );
860 let origin = write_point(&mut writer, Vec3::default())?;
861 let direction_z = writer.add("DIRECTION('',(0.,0.,1.))");
862 let direction_x = writer.add("DIRECTION('',(1.,0.,0.))");
863 let axis = writer.add(format!(
864 "AXIS2_PLACEMENT_3D('',#{origin},#{direction_z},#{direction_x})"
865 ));
866
867 let mut solid_ids = Vec::new();
868 for solid in solids {
869 let band = KernelTolerances::for_solid(solid, 1e-7).export_knit;
873 let mut vertex_ids = HashMap::<u64, usize>::default();
874 let mut edge_ids = HashMap::<u64, usize>::default();
875 let mut surfaces = HashMap::<usize, (usize, bool, EmittedSurface)>::default();
876 for shell in &solid.shells {
877 for face in &shell.faces {
882 let key = surface_key(face);
883 if surfaces.contains_key(&key) {
884 continue;
885 }
886 let entry = match write_analytic_surface(&mut writer, &face.surface)? {
887 Some(triple) => triple,
888 None => (
889 write_surface(&mut writer, &face.surface)?,
890 false,
891 EmittedSurface::Spline,
892 ),
893 };
894 surfaces.insert(key, entry);
895 }
896
897 let mut edge_uses = HashMap::<u64, Vec<CoedgeUse>>::default();
900 let mut edge_order: Vec<u64> = Vec::new();
901 for face in &shell.faces {
902 for loop_record in &face.loops {
903 for coedge in &loop_record.coedges {
904 let edge = edge_for(solid, coedge.edge_id)?;
905 if edge.degenerate {
906 continue;
907 }
908 let uses = edge_uses.entry(edge.id).or_default();
909 if uses.is_empty() {
910 edge_order.push(edge.id);
911 }
912 uses.push(CoedgeUse {
913 surface_key: surface_key(face),
914 face,
915 coedge,
916 });
917 }
918 }
919 }
920
921 for edge_id in &edge_order {
923 if edge_ids.contains_key(edge_id) {
924 continue;
925 }
926 let edge = edge_for(solid, *edge_id)?;
927 let subcurve = edge_subcurve(edge)?;
928 let (curve, emitted_curve) = match write_analytic_curve(&mut writer, &subcurve)? {
929 Some(pair) => pair,
930 None => (
931 write_curve(&mut writer, &subcurve)?,
932 EmittedCurve::Spline { curve: subcurve },
933 ),
934 };
935 let uses = &edge_uses[edge_id];
936 let seam = uses.len() == 2 && uses[0].surface_key == uses[1].surface_key;
940 let mut pcurves: Vec<(usize, Pcurve2d)> = Vec::new();
941 let mut omitted = 0usize;
942 if uses.len() == 2 {
943 let mut ordered: Vec<&CoedgeUse> = uses.iter().collect();
947 if seam && !ordered[0].coedge.forward {
948 ordered.swap(0, 1);
949 }
950 for coedge_use in ordered {
951 let oriented = if coedge_use.coedge.forward {
956 coedge_use.coedge.pcurve.clone()
957 } else {
958 coedge_use.coedge.pcurve.reversed()?
959 };
960 let (surface_id, _, emitted_surface) = &surfaces[&coedge_use.surface_key];
961 let outcome = build_pcurve(
962 &coedge_use.face.surface,
963 emitted_surface,
964 &emitted_curve,
965 &oriented,
966 band,
967 )?;
968 match outcome.curve {
969 Some(curve_2d) => {
970 report.max_pcurve_deviation =
971 report.max_pcurve_deviation.max(outcome.deviation);
972 pcurves.push((*surface_id, curve_2d));
973 }
974 None => {
975 omitted += 1;
976 if outcome.deviation.is_finite() {
977 report.worst_omitted_deviation =
978 report.worst_omitted_deviation.max(outcome.deviation);
979 } else {
980 report.worst_omitted_deviation = f64::INFINITY;
981 }
982 }
983 }
984 }
985 }
986 if seam && pcurves.len() != 2 {
990 omitted += pcurves.len();
991 pcurves.clear();
992 }
993 report.pcurves_omitted += omitted;
994 report.pcurves_written += pcurves.len();
995 let geometry = if pcurves.is_empty() {
996 report.bare_curves += 1;
997 curve
998 } else {
999 let ids = pcurves
1000 .iter()
1001 .map(|(surface_id, curve_2d)| {
1002 write_pcurve_entity(
1003 &mut writer,
1004 *surface_id,
1005 parametric_context,
1006 curve_2d,
1007 )
1008 })
1009 .collect::<Result<Vec<_>, String>>()?;
1010 let keyword = if seam {
1011 report.seam_curves += 1;
1012 "SEAM_CURVE"
1013 } else {
1014 report.surface_curves += 1;
1015 "SURFACE_CURVE"
1016 };
1017 writer.add(format!(
1023 "{keyword}('',#{curve},{},.CURVE_3D.)",
1024 id_list(&ids)
1025 ))
1026 };
1027 let start = vertex_step_id(&mut writer, &mut vertex_ids, solid, edge.start_vertex_id)?;
1028 let end = vertex_step_id(&mut writer, &mut vertex_ids, solid, edge.end_vertex_id)?;
1029 let step_id =
1030 writer.add(format!("EDGE_CURVE('',#{start},#{end},#{geometry},.T.)"));
1031 edge_ids.insert(edge.id, step_id);
1032 }
1033
1034 let mut face_ids = Vec::new();
1036 for face in &shell.faces {
1037 let mut bound_ids = Vec::new();
1038 for (loop_index, loop_record) in face.loops.iter().enumerate() {
1039 let mut oriented_edges = Vec::new();
1040 for coedge in &loop_record.coedges {
1041 let edge = edge_for(solid, coedge.edge_id)?;
1042 if edge.degenerate {
1043 continue;
1044 }
1045 let edge_id = *edge_ids
1046 .get(&edge.id)
1047 .ok_or_else(|| format!("export_step: unwritten edge {}", edge.id))?;
1048 let orientation = if coedge.forward { ".T." } else { ".F." };
1049 oriented_edges.push(
1050 writer.add(format!("ORIENTED_EDGE('',*,*,#{edge_id},{orientation})")),
1051 );
1052 }
1053 let kind = if loop_index == 0 {
1054 "FACE_OUTER_BOUND"
1055 } else {
1056 "FACE_BOUND"
1057 };
1058 if oriented_edges.is_empty() {
1059 let Some(coedge) = loop_record.coedges.first() else {
1072 continue;
1073 };
1074 let collapsed = edge_for(solid, coedge.edge_id)?;
1075 let vertex = vertex_step_id(
1076 &mut writer,
1077 &mut vertex_ids,
1078 solid,
1079 collapsed.start_vertex_id,
1080 )?;
1081 let vertex_loop = writer.add(format!("VERTEX_LOOP('',#{vertex})"));
1082 report.vertex_loops += 1;
1083 bound_ids.push(writer.add(format!("{kind}('',#{vertex_loop},.T.)")));
1084 continue;
1085 }
1086 let edge_loop =
1087 writer.add(format!("EDGE_LOOP('',{})", id_list(&oriented_edges)));
1088 bound_ids.push(writer.add(format!("{kind}('',#{edge_loop},.T.)")));
1089 }
1090 let (surface, flipped, _) = surfaces[&surface_key(face)];
1091 let sense = if face.same_sense != flipped {
1095 ".T."
1096 } else {
1097 ".F."
1098 };
1099 face_ids.push(writer.add(format!(
1100 "ADVANCED_FACE('',{},#{surface},{sense})",
1101 id_list(&bound_ids)
1102 )));
1103 }
1104 let closed_shell = writer.add(format!("CLOSED_SHELL('',{})", id_list(&face_ids)));
1105 solid_ids.push(writer.add(format!(
1106 "MANIFOLD_SOLID_BREP('{safe_name}',#{closed_shell})"
1107 )));
1108 }
1109 }
1110 let mut items = vec![axis];
1111 items.extend(&solid_ids);
1112 let representation = writer.add(format!(
1113 "ADVANCED_BREP_SHAPE_REPRESENTATION('',{},#{geometry_context})",
1114 id_list(&items)
1115 ));
1116 writer.add(format!(
1117 "SHAPE_DEFINITION_REPRESENTATION(#{product_shape},#{representation})"
1118 ));
1119 let safe_timestamp = step_string(timestamp);
1120 let output = [
1121 "ISO-10303-21;".to_string(),
1122 "HEADER;".to_string(),
1123 "FILE_DESCRIPTION((''),'2;1');".to_string(),
1124 format!(
1125 "FILE_NAME('{safe_name}.step','{safe_timestamp}',(''),(''),'brep-kernel-rs','brep-kernel-rs','');"
1126 ),
1127 "FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));".to_string(),
1128 "ENDSEC;".to_string(),
1129 "DATA;".to_string(),
1130 writer.data(),
1131 "ENDSEC;".to_string(),
1132 "END-ISO-10303-21;".to_string(),
1133 String::new(),
1134 ]
1135 .join("\n");
1136 let manifold_issues = audit_step_manifold(&output);
1137 if !manifold_issues.is_empty() {
1138 return Err(format!(
1139 "export_step: emitted AP214 manifold audit failed: {}",
1140 manifold_issues.join("; ")
1141 ));
1142 }
1143 let pcurve_issues = audit_step_pcurves(&output);
1144 if !pcurve_issues.is_empty() {
1145 return Err(format!(
1146 "export_step: emitted AP214 pcurve audit failed: {}",
1147 pcurve_issues.join("; ")
1148 ));
1149 }
1150 report.text = output;
1151 Ok(report)
1152}
1153
1154fn vertex_step_id(
1156 writer: &mut StepWriter,
1157 vertex_ids: &mut HashMap<u64, usize>,
1158 solid: &BrepSolid,
1159 id: u64,
1160) -> Result<usize, String> {
1161 if let Some(step_id) = vertex_ids.get(&id) {
1162 return Ok(*step_id);
1163 }
1164 let point = write_point(writer, vertex_for(solid, id)?.point)?;
1165 let step_id = writer.add(format!("VERTEX_POINT('',#{point})"));
1166 vertex_ids.insert(id, step_id);
1167 Ok(step_id)
1168}
1169
1170fn step_entity_bodies(step: &str) -> HashMap<u64, &str> {
1173 step.lines()
1174 .filter_map(|line| {
1175 let rest = line.strip_prefix('#')?;
1176 let (digits, body) = rest.split_once('=')?;
1177 Some((
1178 digits.parse::<u64>().ok()?,
1179 body.trim_end().trim_end_matches(';'),
1180 ))
1181 })
1182 .collect()
1183}
1184
1185fn step_entity_refs(body: &str) -> Vec<u64> {
1189 let mut refs = Vec::new();
1190 let bytes = body.as_bytes();
1191 let mut index = 0;
1192 while index < bytes.len() {
1193 if bytes[index] == b'#' {
1194 let start = index + 1;
1195 let mut end = start;
1196 while end < bytes.len() && bytes[end].is_ascii_digit() {
1197 end += 1;
1198 }
1199 if end > start {
1200 if let Ok(id) = body[start..end].parse::<u64>() {
1201 refs.push(id);
1202 }
1203 }
1204 index = end;
1205 } else {
1206 index += 1;
1207 }
1208 }
1209 refs
1210}
1211
1212pub fn audit_step_pcurves(step: &str) -> Vec<String> {
1225 let bodies = step_entity_bodies(step);
1226 let mut issues = Vec::new();
1227 for (id, body) in &bodies {
1228 if !body.starts_with("EDGE_CURVE(") {
1229 continue;
1230 }
1231 let refs = step_entity_refs(body);
1232 let Some(geometry) = refs.get(2) else {
1233 issues.push(format!("EDGE_CURVE #{id} has no edge_geometry"));
1234 continue;
1235 };
1236 let Some(wrapper) = bodies.get(geometry) else {
1237 issues.push(format!("EDGE_CURVE #{id} references missing #{geometry}"));
1238 continue;
1239 };
1240 let seam = wrapper.starts_with("SEAM_CURVE(");
1241 if !seam && !wrapper.starts_with("SURFACE_CURVE(") {
1242 continue;
1243 }
1244 let wrapper_refs = step_entity_refs(wrapper);
1245 let pcurves = &wrapper_refs[wrapper_refs.len().min(1)..];
1246 if pcurves.is_empty() || pcurves.len() > 2 || (seam && pcurves.len() != 2) {
1247 issues.push(format!(
1248 "#{geometry} carries {} associated geometries",
1249 pcurves.len()
1250 ));
1251 continue;
1252 }
1253 let mut surfaces = Vec::new();
1254 for pcurve in pcurves {
1255 let Some(pcurve_body) = bodies.get(pcurve) else {
1256 issues.push(format!("#{geometry} references missing #{pcurve}"));
1257 continue;
1258 };
1259 if !pcurve_body.starts_with("PCURVE(") {
1260 issues.push(format!("#{geometry} associate #{pcurve} is not a PCURVE"));
1261 continue;
1262 }
1263 let pcurve_refs = step_entity_refs(pcurve_body);
1264 let representation = pcurve_refs.get(1).and_then(|id| bodies.get(id));
1265 if !representation.is_some_and(|body| body.starts_with("DEFINITIONAL_REPRESENTATION("))
1266 {
1267 issues.push(format!(
1268 "PCURVE #{pcurve} has no DEFINITIONAL_REPRESENTATION"
1269 ));
1270 }
1271 if let Some(surface) = pcurve_refs.first() {
1272 surfaces.push(*surface);
1273 }
1274 }
1275 if surfaces.len() == 2 && (surfaces[0] == surfaces[1]) != seam {
1276 issues.push(format!(
1277 "#{geometry} pcurves name {} surface(s) but it is a {}",
1278 if surfaces[0] == surfaces[1] { 1 } else { 2 },
1279 if seam { "SEAM_CURVE" } else { "SURFACE_CURVE" }
1280 ));
1281 }
1282 }
1283 issues.sort();
1284 issues
1285}
1286
1287pub fn audit_step_manifold(step: &str) -> Vec<String> {
1292 let marker = "ORIENTED_EDGE('',*,*,#";
1293 let mut uses = HashMap::<u64, Vec<bool>>::default();
1294 for line in step.lines() {
1295 let Some(offset) = line.find(marker) else {
1296 continue;
1297 };
1298 let rest = &line[offset + marker.len()..];
1299 let digits = rest
1300 .chars()
1301 .take_while(|character| character.is_ascii_digit())
1302 .collect::<String>();
1303 let Ok(edge_id) = digits.parse::<u64>() else {
1304 continue;
1305 };
1306 let suffix = &rest[digits.len()..];
1307 let sense = suffix.starts_with(",.T.");
1308 uses.entry(edge_id).or_default().push(sense);
1309 }
1310 let mut issues = uses
1311 .into_iter()
1312 .filter_map(|(edge, senses)| {
1313 (senses.len() != 2 || senses[0] == senses[1]).then(|| {
1314 format!(
1315 "EDGE_CURVE #{edge} has {} uses with senses {:?}",
1316 senses.len(),
1317 senses
1318 )
1319 })
1320 })
1321 .collect::<Vec<_>>();
1322 issues.sort();
1323 issues
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328 use super::*;
1329 use crate::{
1330 boolean_operation, import_step, make_box_brep, make_cone_brep, make_cylinder_brep,
1331 make_cylinder_surface, make_sphere_brep, make_torus_brep, solid_mass_properties,
1332 BooleanOperation, BooleanOptions,
1333 };
1334
1335 #[test]
1336 fn box_step_contains_exact_manifold_topology() {
1337 let box_solid = make_box_brep(Vec3::default(), 2.0, 3.0, 4.0).unwrap();
1338 let step = export_step(&[box_solid], "box", "millimeter", "2026-07-27T00:00:00").unwrap();
1339 assert!(step.starts_with("ISO-10303-21;\nHEADER;"));
1340 assert!(audit_step_manifold(&step).is_empty());
1341 assert!(step.contains("MANIFOLD_SOLID_BREP('box'"));
1342 assert_eq!(step.matches("ADVANCED_FACE(").count(), 6);
1343 assert_eq!(step.matches("EDGE_CURVE(").count(), 12);
1344 assert!(step.ends_with("END-ISO-10303-21;\n"));
1345 }
1346
1347 #[test]
1348 fn step_manifold_audit_rejects_single_and_same_sense_uses() {
1349 let single = "#1=ORIENTED_EDGE('',*,*,#9,.T.);";
1350 assert_eq!(audit_step_manifold(single).len(), 1);
1351 let same = "#1=ORIENTED_EDGE('',*,*,#9,.T.);\n\
1352 #2=ORIENTED_EDGE('',*,*,#9,.T.);";
1353 assert_eq!(audit_step_manifold(same).len(), 1);
1354 let good = "#1=ORIENTED_EDGE('',*,*,#9,.T.);\n\
1355 #2=ORIENTED_EDGE('',*,*,#9,.F.);";
1356 assert!(audit_step_manifold(good).is_empty());
1357 }
1358
1359 #[test]
1360 fn unrecognized_surfaces_and_curves_still_write_rational_complex_entities() {
1361 let mut writer = StepWriter::default();
1364 let cylinder =
1365 make_cylinder_surface(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
1366 write_surface(&mut writer, &cylinder).unwrap();
1367 let split_arc = make_arc(
1368 Vec3::default(),
1369 Vec3::new(1.0, 0.0, 0.0),
1370 Vec3::new(0.0, 1.0, 0.0),
1371 2.0,
1372 0.0,
1373 std::f64::consts::TAU,
1374 )
1375 .unwrap()
1376 .split(0.37)
1377 .unwrap()
1378 .1;
1379 assert!(
1380 recognize_circular_arc(&split_arc).is_none(),
1381 "a split subrange is not the pristine make_arc net"
1382 );
1383 assert!(write_analytic_curve(&mut writer, &split_arc)
1384 .unwrap()
1385 .is_none());
1386 write_curve(&mut writer, &split_arc).unwrap();
1387 let data = writer.data();
1388 assert!(data.contains("RATIONAL_B_SPLINE_SURFACE"));
1389 assert!(data.contains("RATIONAL_B_SPLINE_CURVE"));
1390 }
1391
1392 fn assert_analytic_round_trip(
1396 label: &str,
1397 original: &BrepSolid,
1398 expected_markers: &[&str],
1399 forbid_nurbs: bool,
1400 ) -> BrepSolid {
1401 let step = export_step(std::slice::from_ref(original), label, "millimeter", "fixed")
1402 .expect("export");
1403 for marker in expected_markers {
1404 assert!(step.contains(marker), "{label}: missing {marker}");
1405 }
1406 if forbid_nurbs {
1407 assert!(
1408 !step.contains("B_SPLINE"),
1409 "{label}: expected a fully analytic export"
1410 );
1411 }
1412 assert!(audit_step_manifold(&step).is_empty(), "{label}: audit");
1413 let imported = import_step(&step).expect("import");
1414 assert_eq!(imported.len(), 1, "{label}: one solid");
1415 let solid = imported.into_iter().next().unwrap();
1416 assert!(
1417 solid.validate().is_empty(),
1418 "{label}: imported solid invalid: {:?}",
1419 solid.validate()
1420 );
1421 let original_volume = solid_mass_properties(original).unwrap().volume;
1422 let volume = solid_mass_properties(&solid).unwrap().volume;
1423 let relative = ((volume - original_volume) / original_volume).abs();
1424 assert!(
1425 relative < 1e-6,
1426 "{label}: volume {volume} vs {original_volume} (rel {relative:.3e})"
1427 );
1428 for shell in &solid.shells {
1429 for face in &shell.faces {
1430 assert!(
1431 face.surface.analytic().is_some(),
1432 "{label}: imported face {} did not re-recognize as analytic",
1433 face.id
1434 );
1435 }
1436 }
1437 solid
1438 }
1439
1440 #[test]
1441 fn box_round_trips_through_plane_and_line_entities() {
1442 let solid = make_box_brep(Vec3::new(-1.0, 0.5, 2.0), 2.0, 3.0, 4.0).unwrap();
1443 let step = export_step(std::slice::from_ref(&solid), "box", "millimeter", "fixed").unwrap();
1444 assert_eq!(step.matches("PLANE(").count(), 6);
1445 assert_eq!(step.matches("LINE(").count(), 36);
1448 assert_analytic_round_trip("box", &solid, &["PLANE(", "LINE("], true);
1449 }
1450
1451 #[test]
1456 fn box_pcurves_cover_every_edge() {
1457 let solid = make_box_brep(Vec3::new(-1.0, 0.5, 2.0), 2.0, 3.0, 4.0).unwrap();
1458 let report =
1459 export_step_report(std::slice::from_ref(&solid), "box", "millimeter", "fixed").unwrap();
1460 assert_eq!(report.pcurves_written, 24);
1461 assert_eq!(report.pcurves_omitted, 0);
1462 assert_eq!(report.surface_curves, 12);
1463 assert_eq!(report.seam_curves, 0);
1464 assert_eq!(report.bare_curves, 0);
1465 assert_eq!(report.text.matches("SURFACE_CURVE(").count(), 12);
1466 assert_eq!(report.text.matches("PCURVE(").count(), 24);
1467 assert_eq!(
1468 report.text.matches("DEFINITIONAL_REPRESENTATION(").count(),
1469 24
1470 );
1471 assert_eq!(
1473 report
1474 .text
1475 .matches("PARAMETRIC_REPRESENTATION_CONTEXT()")
1476 .count(),
1477 1
1478 );
1479 assert!(
1480 !report.text.contains("B_SPLINE"),
1481 "an all-planar solid must stay B-spline-free on both sides"
1482 );
1483 assert!(audit_step_pcurves(&report.text).is_empty());
1484 assert_eq!(report.max_pcurve_deviation, 0.0);
1488 }
1489
1490 #[test]
1497 fn rotated_box_pcurves_stay_exact_off_axis() {
1498 let (sin, cos) = 0.7_f64.sin_cos();
1499 let axis = Vec3::new(0.3, 0.7, 0.2).normalized().unwrap();
1500 let (ax, ay, az) = (axis.x, axis.y, axis.z);
1501 let one = 1.0 - cos;
1502 let rotation = crate::AffineTransform::new([
1503 cos + ax * ax * one,
1504 ax * ay * one - az * sin,
1505 ax * az * one + ay * sin,
1506 0.0,
1507 ay * ax * one + az * sin,
1508 cos + ay * ay * one,
1509 ay * az * one - ax * sin,
1510 0.0,
1511 az * ax * one - ay * sin,
1512 az * ay * one + ax * sin,
1513 cos + az * az * one,
1514 0.0,
1515 0.0,
1516 0.0,
1517 0.0,
1518 1.0,
1519 ])
1520 .unwrap();
1521 let solid = crate::transform_brep(
1522 &make_box_brep(Vec3::new(-1.0, 0.5, 2.0), 2.0, 3.0, 4.0).unwrap(),
1523 rotation,
1524 false,
1525 )
1526 .unwrap();
1527 let report =
1528 export_step_report(std::slice::from_ref(&solid), "tilted", "millimeter", "fixed")
1529 .unwrap();
1530 assert_eq!(report.pcurves_written, 24);
1531 assert_eq!(report.pcurves_omitted, 0);
1532 assert!(
1533 report.max_pcurve_deviation < 1e-13,
1534 "off-axis pcurve deviation {:.3e} exceeded 1e-13",
1535 report.max_pcurve_deviation
1536 );
1537 assert!(audit_step_pcurves(&report.text).is_empty());
1538 assert!(import_step(&report.text).is_ok());
1539 }
1540
1541 #[test]
1553 fn revolution_carriers_cover_every_edge_exactly() {
1554 let axis = Vec3::new(0.0, 0.0, 1.0);
1555 for (label, solid) in [
1556 (
1557 "cylinder",
1558 make_cylinder_brep(Vec3::new(1.0, -2.0, 0.5), axis, 2.0, 5.0).unwrap(),
1559 ),
1560 (
1561 "cylinder_reversed",
1562 make_cylinder_brep(Vec3::new(0.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0), 2.0, 5.0)
1563 .unwrap(),
1564 ),
1565 (
1566 "frustum",
1567 make_cone_brep(Vec3::new(0.5, 0.5, -1.0), axis, 3.0, 1.5, 5.0).unwrap(),
1568 ),
1569 (
1570 "frustum_growing",
1571 make_cone_brep(Vec3::new(0.5, 0.5, -1.0), axis, 1.5, 3.0, 5.0).unwrap(),
1572 ),
1573 ] {
1574 let report =
1575 export_step_report(std::slice::from_ref(&solid), label, "millimeter", "fixed")
1576 .unwrap();
1577 assert_eq!(report.pcurves_written, 6, "{label}: written");
1579 assert_eq!(report.pcurves_omitted, 0, "{label}: omitted");
1580 assert_eq!(report.surface_curves, 2, "{label}: surface curves");
1581 assert_eq!(report.seam_curves, 1, "{label}: seam curves");
1582 assert_eq!(report.bare_curves, 0, "{label}: bare curves");
1583 assert_eq!(
1585 report.text.matches("AXIS2_PLACEMENT_2D(").count(),
1586 2,
1587 "{label}: 2D circles"
1588 );
1589 assert!(
1590 !report.text.contains("B_SPLINE"),
1591 "{label}: an analytic solid must export analytic on both sides"
1592 );
1593 assert!(audit_step_pcurves(&report.text).is_empty(), "{label}: audit");
1594 assert!(
1599 report.max_pcurve_deviation < 1e-12,
1600 "{label}: pcurve deviation {:.3e} exceeded 1e-12",
1601 report.max_pcurve_deviation
1602 );
1603 }
1604 }
1605
1606 #[test]
1612 fn sphere_pole_and_seam() {
1613 let solid =
1614 make_sphere_brep(Vec3::new(2.0, 1.0, -1.0), 3.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1615 let report =
1616 export_step_report(std::slice::from_ref(&solid), "sphere", "millimeter", "fixed")
1617 .unwrap();
1618 assert_eq!(report.seam_curves, 1);
1619 assert_eq!(report.pcurves_written, 2);
1620 assert_eq!(report.pcurves_omitted, 0);
1621 assert_eq!(report.text.matches("SEAM_CURVE(").count(), 1);
1622 assert!(!report.text.contains("B_SPLINE"));
1623 assert!(audit_step_pcurves(&report.text).is_empty());
1624 assert!(
1626 report.max_pcurve_deviation < 1e-12,
1627 "sphere pcurve deviation {:.3e}",
1628 report.max_pcurve_deviation
1629 );
1630 }
1631
1632 #[test]
1636 fn torus_seams() {
1637 let solid =
1638 make_torus_brep(Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, 1.0), 5.0, 1.5).unwrap();
1639 let report =
1640 export_step_report(std::slice::from_ref(&solid), "torus", "millimeter", "fixed")
1641 .unwrap();
1642 assert_eq!(report.seam_curves, 2);
1643 assert_eq!(report.surface_curves, 0);
1644 assert_eq!(report.pcurves_written, 4);
1645 assert_eq!(report.pcurves_omitted, 0);
1646 assert!(!report.text.contains("B_SPLINE"));
1647 assert!(audit_step_pcurves(&report.text).is_empty());
1648 assert!(
1650 report.max_pcurve_deviation < 1e-12,
1651 "torus pcurve deviation {:.3e}",
1652 report.max_pcurve_deviation
1653 );
1654 }
1655
1656 fn step_numbers(body: &str) -> Vec<f64> {
1660 let characters: Vec<char> = body.chars().collect();
1661 let mut values = Vec::new();
1662 let mut index = 0;
1663 while index < characters.len() {
1664 if characters[index] == '#' {
1665 index += 1;
1666 while index < characters.len() && characters[index].is_ascii_digit() {
1667 index += 1;
1668 }
1669 continue;
1670 }
1671 let signed = characters[index] == '-'
1672 && index + 1 < characters.len()
1673 && characters[index + 1].is_ascii_digit();
1674 if !characters[index].is_ascii_digit() && !signed {
1675 index += 1;
1676 continue;
1677 }
1678 let start = index;
1679 if signed {
1680 index += 1;
1681 }
1682 while index < characters.len() && characters[index].is_ascii_digit() {
1683 index += 1;
1684 }
1685 if index < characters.len() && characters[index] == '.' {
1686 index += 1;
1687 while index < characters.len() && characters[index].is_ascii_digit() {
1688 index += 1;
1689 }
1690 values.push(
1691 characters[start..index]
1692 .iter()
1693 .collect::<String>()
1694 .parse()
1695 .expect("number"),
1696 );
1697 }
1698 }
1699 values
1700 }
1701
1702 #[test]
1710 fn pcurve_parameter_shared_with_analytic_curve() {
1711 let solid =
1712 make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
1713 let step =
1714 export_step(std::slice::from_ref(&solid), "cylinder", "millimeter", "fixed").unwrap();
1715 let bodies = step_entity_bodies(&step);
1716 let body = |id: u64| -> &str { bodies[&id] };
1717 let refs = |id: u64| step_entity_refs(body(id));
1718 let numbers = |id: u64| step_numbers(body(id));
1719 let vector3 = |id: u64| {
1720 let values = numbers(id);
1721 Vec3::new(values[0], values[1], values[2])
1722 };
1723
1724 let surface_id = bodies
1726 .iter()
1727 .find(|(_, text)| text.starts_with("CYLINDRICAL_SURFACE("))
1728 .map(|(id, _)| *id)
1729 .expect("cylindrical surface");
1730 let radius = numbers(surface_id)[0];
1731 let placement = refs(refs(surface_id)[0]);
1732 let origin = vector3(placement[0]);
1733 let axis = vector3(placement[1]);
1734 let x_axis = vector3(placement[2]);
1735 let y_axis = axis.cross(x_axis);
1736 let evaluate = |u: f64, v: f64| {
1738 origin
1739 .add(x_axis.scale(radius * u.cos()))
1740 .add(y_axis.scale(radius * u.sin()))
1741 .add(axis.scale(v))
1742 };
1743
1744 let mut checked = 0;
1745 let mut worst: f64 = 0.0;
1746 for (id, text) in &bodies {
1747 if !text.starts_with("SURFACE_CURVE(") && !text.starts_with("SEAM_CURVE(") {
1748 continue;
1749 }
1750 let bundle = refs(*id);
1751 let curve_id = bundle[0];
1755 let curve_body = body(curve_id);
1756 let (domain, curve_3d): (f64, Box<dyn Fn(f64) -> Vec3>) =
1757 if curve_body.starts_with("LINE(") {
1758 let parts = refs(curve_id);
1759 let start = vector3(parts[0]);
1760 let vector = refs(parts[1]);
1761 let magnitude = numbers(parts[1])[0];
1762 let direction = vector3(vector[0]);
1763 (1.0, Box::new(move |s| start.add(direction.scale(magnitude * s))))
1764 } else {
1765 let arc = refs(refs(curve_id)[0]);
1766 let radius = numbers(curve_id)[0];
1767 let center = vector3(arc[0]);
1768 let arc_axis = vector3(arc[1]);
1769 let arc_x = vector3(arc[2]);
1770 let arc_y = arc_axis.cross(arc_x);
1771 (
1772 std::f64::consts::TAU,
1773 Box::new(move |a: f64| {
1774 center
1775 .add(arc_x.scale(radius * a.cos()))
1776 .add(arc_y.scale(radius * a.sin()))
1777 }),
1778 )
1779 };
1780 for pcurve_id in &bundle[1..] {
1781 let pcurve = refs(*pcurve_id);
1782 if pcurve[0] != surface_id {
1783 continue; }
1785 let geometry = refs(refs(*pcurve_id)[1])[0];
1786 assert!(body(geometry).starts_with("LINE("), "iso-lines only");
1787 let parts = refs(geometry);
1788 let point = numbers(parts[0]);
1789 let magnitude = numbers(parts[1])[0];
1790 let direction = numbers(refs(parts[1])[0]);
1791 for step_index in 0..=40 {
1792 let s = domain * step_index as f64 / 40.0;
1793 let u = point[0] + s * magnitude * direction[0];
1794 let v = point[1] + s * magnitude * direction[1];
1795 worst = worst.max(evaluate(u, v).sub(curve_3d(s)).length());
1796 }
1797 checked += 1;
1798 }
1799 }
1800 assert_eq!(checked, 4, "two rim circles and both halves of the seam");
1801 assert!(
1805 worst < 1e-12,
1806 "independent re-evaluation deviated {worst:.3e} mm"
1807 );
1808 }
1809
1810 #[test]
1823 fn pointed_cone_seam_exports_seam_curve_with_both_pcurves() {
1824 let solid =
1825 make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 0.0, 5.0).unwrap();
1826 let report =
1827 export_step_report(std::slice::from_ref(&solid), "cone", "millimeter", "fixed")
1828 .unwrap();
1829 assert_eq!(report.seam_curves, 1);
1830 assert_eq!(report.surface_curves, 1);
1831 assert_eq!(report.pcurves_written, 4);
1833 assert_eq!(report.pcurves_omitted, 0);
1834 assert_eq!(report.bare_curves, 0);
1835 assert_eq!(report.text.matches("SEAM_CURVE(").count(), 1);
1836 assert!(audit_step_pcurves(&report.text).is_empty());
1837 assert!(
1843 report.max_pcurve_deviation < 1e-4,
1844 "pcurve deviation {:.3e} exceeded 1e-4",
1845 report.max_pcurve_deviation
1846 );
1847 let imported = import_step(&report.text).expect("import");
1848 let volume = solid_mass_properties(&imported[0]).unwrap().volume;
1849 let expected = solid_mass_properties(&solid).unwrap().volume;
1850 assert!(((volume - expected) / expected).abs() < 1e-6);
1851 }
1852
1853 #[test]
1870 fn boolean_and_fillet_solids_reach_full_pcurve_coverage() {
1871 let axis = Vec3::new(0.0, 0.0, 1.0);
1872 let block = make_box_brep(Vec3::new(-3.0, -3.0, 0.0), 6.0, 6.0, 4.0).unwrap();
1873 let drill = make_cylinder_brep(Vec3::new(0.0, 0.0, -1.0), axis, 1.5, 6.0).unwrap();
1874 let cut = boolean_operation(
1875 &block,
1876 &drill,
1877 BooleanOperation::Subtract,
1878 &BooleanOptions::default(),
1879 )
1880 .unwrap();
1881 let report =
1882 export_step_report(std::slice::from_ref(&cut), "cut", "millimeter", "fixed").unwrap();
1883 assert_eq!(report.pcurves_omitted, 0, "boolean: omitted");
1884 assert_eq!(report.bare_curves, 0, "boolean: bare");
1885 assert_eq!(report.pcurves_written, 30, "boolean: written");
1886 assert_eq!(report.seam_curves, 1, "boolean: the drill's seam");
1887 assert!(audit_step_pcurves(&report.text).is_empty());
1888 assert!(
1892 report.max_pcurve_deviation < 1e-5,
1893 "boolean pcurve deviation {:.3e} exceeded 1e-5",
1894 report.max_pcurve_deviation
1895 );
1896
1897 let plain = make_box_brep(Vec3::default(), 6.0, 6.0, 4.0).unwrap();
1898 let rounded = crate::fillet_edges(&plain, &[Vec3::new(0.0, 0.0, 2.0)], None, 0.8, false, None)
1899 .expect("fillet");
1900 let report =
1901 export_step_report(std::slice::from_ref(&rounded), "fillet", "millimeter", "fixed")
1902 .unwrap();
1903 assert_eq!(report.pcurves_omitted, 0, "fillet: omitted");
1904 assert_eq!(report.pcurves_written, 30, "fillet: written");
1905 assert!(audit_step_pcurves(&report.text).is_empty());
1906 assert!(
1908 report.max_pcurve_deviation < 1e-6,
1909 "fillet pcurve deviation {:.3e} exceeded 1e-6",
1910 report.max_pcurve_deviation
1911 );
1912 assert!(import_step(&report.text).is_ok());
1913 }
1914
1915 #[test]
1933 fn vertex_loops_survive_a_re_export() {
1934 let text = include_str!(concat!(
1935 env!("CARGO_MANIFEST_DIR"),
1936 "/tests/fixtures/step-import/abc_00000036.step"
1937 ));
1938 let solids = import_step(text).expect("import");
1939 assert_eq!(solids.len(), 2);
1940 let report =
1941 export_step_report(&solids, "abc_00000036", "millimeter", "fixed").expect("export");
1942 assert_eq!(report.vertex_loops, 2);
1945 assert_eq!(report.text.matches("VERTEX_LOOP(").count(), 2);
1946 assert!(audit_step_manifold(&report.text).is_empty());
1947 assert!(audit_step_pcurves(&report.text).is_empty());
1948 let reimported = import_step(&report.text).expect("re-import");
1949 assert_eq!(reimported.len(), 2);
1950 for (index, (before, after)) in solids.iter().zip(&reimported).enumerate() {
1951 assert!(
1952 after.validate().is_empty(),
1953 "solid {index} invalid after re-import: {:?}",
1954 after.validate()
1955 );
1956 let original = solid_mass_properties(before).unwrap().volume;
1957 let volume = solid_mass_properties(after).unwrap().volume;
1958 let relative = ((volume - original) / original).abs();
1959 assert!(
1960 relative < 1e-6,
1961 "solid {index} volume {volume} vs {original} (rel {relative:.3e})"
1962 );
1963 }
1964 }
1965
1966 #[test]
1969 fn step_pcurve_audit_rejects_malformed_bundles() {
1970 let bundle = |surface_curve: &str, pcurve: &str, representation: &str| {
1973 [
1974 "#1=PLANE('',#9);",
1975 representation,
1976 pcurve,
1977 surface_curve,
1978 "#5=EDGE_CURVE('',#10,#11,#4,.T.);",
1979 ]
1980 .join("\n")
1981 };
1982 let representation = "#2=DEFINITIONAL_REPRESENTATION('',(#8),#7);";
1983 let pcurve = "#3=PCURVE('',#1,#2);";
1984 let good = bundle(
1985 "#4=SURFACE_CURVE('',#6,(#3),.CURVE_3D.);",
1986 pcurve,
1987 representation,
1988 );
1989 assert!(audit_step_pcurves(&good).is_empty());
1990 let short_seam = bundle(
1992 "#4=SEAM_CURVE('',#6,(#3),.CURVE_3D.);",
1993 pcurve,
1994 representation,
1995 );
1996 assert_eq!(audit_step_pcurves(&short_seam).len(), 1);
1997 let mislabelled = bundle(
1999 "#4=SURFACE_CURVE('',#6,(#3,#3),.CURVE_3D.);",
2000 pcurve,
2001 representation,
2002 );
2003 assert_eq!(audit_step_pcurves(&mislabelled).len(), 1);
2004 let no_representation = bundle(
2006 "#4=SURFACE_CURVE('',#6,(#3),.CURVE_3D.);",
2007 pcurve,
2008 "#2=REPRESENTATION('',(#8),#7);",
2009 );
2010 assert_eq!(audit_step_pcurves(&no_representation).len(), 1);
2011 let bare = "#1=LINE('',#2,#3);\n#5=EDGE_CURVE('',#10,#11,#1,.T.);";
2014 assert!(audit_step_pcurves(bare).is_empty());
2015 }
2016
2017
2018 #[test]
2019 fn cylinder_round_trips_through_analytic_entities() {
2020 let solid = make_cylinder_brep(
2021 Vec3::new(1.0, -2.0, 0.5),
2022 Vec3::new(0.0, 0.0, 1.0),
2023 2.0,
2024 5.0,
2025 )
2026 .unwrap();
2027 assert_analytic_round_trip(
2028 "cylinder",
2029 &solid,
2030 &[
2031 "CYLINDRICAL_SURFACE(",
2032 "PLANE(",
2033 "CIRCLE(",
2034 "LINE(",
2035 "VECTOR(",
2036 ],
2037 true,
2038 );
2039 }
2040
2041 #[test]
2042 fn cylinder_export_keeps_unit_conversion_entities() {
2043 let cylinder =
2044 make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
2045 let step = export_step(&[cylinder], "cylinder", "inch", "fixed").unwrap();
2046 assert!(step.contains("CYLINDRICAL_SURFACE("));
2047 assert!(step.contains("CONVERSION_BASED_UNIT('INCH'"));
2048 }
2049
2050 #[test]
2051 fn frustum_round_trips_through_conical_surface() {
2052 let solid = make_cone_brep(
2053 Vec3::new(0.5, 0.5, -1.0),
2054 Vec3::new(0.0, 0.0, 1.0),
2055 3.0,
2056 1.5,
2057 5.0,
2058 )
2059 .unwrap();
2060 assert_analytic_round_trip("frustum", &solid, &["CONICAL_SURFACE(", "PLANE("], true);
2061 }
2062
2063 #[test]
2064 fn pointed_cone_wall_stays_nurbs_but_caps_and_rim_export_analytic() {
2065 let solid =
2070 make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 0.0, 5.0).unwrap();
2071 let step =
2072 export_step(std::slice::from_ref(&solid), "cone", "millimeter", "fixed").unwrap();
2073 assert!(!step.contains("CONICAL_SURFACE("));
2074 assert!(step.contains("B_SPLINE_SURFACE"));
2075 assert!(step.contains("PLANE("));
2076 assert!(step.contains("CIRCLE("));
2077 let imported = import_step(&step).expect("import");
2078 let volume = solid_mass_properties(&imported[0]).unwrap().volume;
2079 let expected = solid_mass_properties(&solid).unwrap().volume;
2080 assert!(((volume - expected) / expected).abs() < 1e-6);
2081 }
2082
2083 #[test]
2084 fn sphere_round_trips_through_spherical_surface() {
2085 let solid =
2086 make_sphere_brep(Vec3::new(2.0, 1.0, -1.0), 3.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
2087 assert_analytic_round_trip("sphere", &solid, &["SPHERICAL_SURFACE(", "CIRCLE("], true);
2088 }
2089
2090 #[test]
2091 fn torus_round_trips_through_toroidal_surface() {
2092 let solid =
2093 make_torus_brep(Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, 1.0), 5.0, 1.5).unwrap();
2094 assert_analytic_round_trip("torus", &solid, &["TOROIDAL_SURFACE(", "CIRCLE("], true);
2095 }
2096
2097 #[test]
2098 fn box_minus_cylinder_round_trips_with_analytic_entities() {
2099 let block = make_box_brep(Vec3::new(-3.0, -3.0, 0.0), 6.0, 6.0, 4.0).unwrap();
2100 let drill = make_cylinder_brep(
2101 Vec3::new(0.0, 0.0, -1.0),
2102 Vec3::new(0.0, 0.0, 1.0),
2103 1.5,
2104 6.0,
2105 )
2106 .unwrap();
2107 let cut = boolean_operation(
2108 &block,
2109 &drill,
2110 BooleanOperation::Subtract,
2111 &BooleanOptions::default(),
2112 )
2113 .unwrap();
2114 let solid = assert_analytic_round_trip(
2117 "box_minus_cyl",
2118 &cut,
2119 &["CYLINDRICAL_SURFACE(", "PLANE("],
2120 false,
2121 );
2122 assert_eq!(solid.genus, 1, "through-hole genus survives the round trip");
2123 }
2124}