1use crate::analytic_surface::{circumcenter, AnalyticSurface};
2use crate::topology::{BrepSolid, EdgeRecord, FaceRecord, VertexRecord};
3use crate::{make_arc, KernelTolerances, NurbsCurve, NurbsSurface, Vec3};
4use rustc_hash::FxHashMap as HashMap;
5
6fn step_string(value: &str) -> String {
7 value.replace('\'', "''")
8}
9
10fn real(value: f64) -> Result<String, String> {
11 if !value.is_finite() {
12 return Err(format!("export_step: non-finite number {value}"));
13 }
14 if value == 0.0 {
17 return Ok("0.".into());
18 }
19 if value.fract() == 0.0 && value.abs() < 1e15 {
20 return Ok(format!("{value:.0}."));
21 }
22 let mut output = format!("{value:.15}");
23 while output.ends_with('0') {
24 output.pop();
25 }
26 if output.ends_with('.') {
27 output.push('0');
28 }
29 if output == "-0.0" {
30 output = "0.0".into();
31 }
32 Ok(output)
33}
34
35fn knot_runs(knots: &[f64]) -> (Vec<f64>, Vec<usize>) {
36 let mut values = Vec::new();
37 let mut multiplicities = Vec::new();
38 for &knot in knots {
39 if values
40 .last()
41 .is_some_and(|previous: &f64| (*previous - knot).abs() <= 1e-12)
42 {
43 *multiplicities.last_mut().unwrap() += 1;
44 } else {
45 values.push(knot);
46 multiplicities.push(1);
47 }
48 }
49 (values, multiplicities)
50}
51
52fn edge_subcurve(edge: &EdgeRecord) -> Result<NurbsCurve, String> {
53 let [start, end] = edge.curve.domain()?;
54 let epsilon = (1e-9 * (end - start)).max(2e-9);
55 let mut curve = edge.curve.clone();
56 if edge.t0 > start + epsilon && edge.t0 < end - epsilon {
57 curve = curve.split(edge.t0)?.1;
58 }
59 let domain = curve.domain()?;
60 if edge.t1 < domain[1] - epsilon && edge.t1 > domain[0] + epsilon {
61 curve = curve.split(edge.t1)?.0;
62 }
63 Ok(curve)
64}
65
66#[derive(Default)]
67struct StepWriter {
68 lines: Vec<String>,
69}
70
71impl StepWriter {
72 fn add(&mut self, body: impl Into<String>) -> usize {
73 let id = self.lines.len() + 1;
74 self.lines.push(format!("#{id}={};", body.into()));
75 id
76 }
77
78 fn data(&self) -> String {
79 self.lines.join("\n")
80 }
81}
82
83fn write_point(writer: &mut StepWriter, point: Vec3) -> Result<usize, String> {
84 Ok(writer.add(format!(
85 "CARTESIAN_POINT('',({},{},{}))",
86 real(point.x)?,
87 real(point.y)?,
88 real(point.z)?
89 )))
90}
91
92fn id_list(ids: &[usize]) -> String {
93 format!(
94 "({})",
95 ids.iter()
96 .map(|id| format!("#{id}"))
97 .collect::<Vec<_>>()
98 .join(",")
99 )
100}
101
102fn write_direction(writer: &mut StepWriter, direction: Vec3) -> Result<usize, String> {
103 Ok(writer.add(format!(
104 "DIRECTION('',({},{},{}))",
105 real(direction.x)?,
106 real(direction.y)?,
107 real(direction.z)?
108 )))
109}
110
111fn write_placement(
112 writer: &mut StepWriter,
113 origin: Vec3,
114 axis: Vec3,
115 ref_direction: Vec3,
116) -> Result<usize, String> {
117 let origin = write_point(writer, origin)?;
118 let axis = write_direction(writer, axis)?;
119 let ref_direction = write_direction(writer, ref_direction)?;
120 Ok(writer.add(format!(
121 "AXIS2_PLACEMENT_3D('',#{origin},#{axis},#{ref_direction})"
122 )))
123}
124
125fn write_analytic_surface(
133 writer: &mut StepWriter,
134 surface: &NurbsSurface,
135) -> Result<Option<(usize, bool)>, String> {
136 let Some(analytic) = surface.analytic() else {
137 return Ok(None);
138 };
139 match analytic {
140 AnalyticSurface::Plane {
141 origin,
142 u_dir,
143 v_dir,
144 ..
145 } => {
146 let (Ok(normal), Ok(x_axis)) = (u_dir.cross(*v_dir).normalized(), u_dir.normalized())
149 else {
150 return Ok(None);
151 };
152 let placement = write_placement(writer, *origin, normal, x_axis)?;
153 Ok(Some((writer.add(format!("PLANE('',#{placement})")), false)))
154 }
155 AnalyticSurface::RuledRevolution {
156 frame,
157 rho0,
158 rho1,
159 height,
160 } => {
161 let flipped = *height < 0.0;
165 let radius_scale = 1.0 + rho0.abs().max(rho1.abs());
166 if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
167 if *rho0 <= 0.0 {
168 return Ok(None);
169 }
170 let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
171 return Ok(Some((
172 writer.add(format!(
173 "CYLINDRICAL_SURFACE('',#{placement},{})",
174 real(*rho0)?
175 )),
176 flipped,
177 )));
178 }
179 let slope = (rho1 - rho0) / height;
185 let apex_margin = 2.0 * (1e-4 * height.abs().max(1.0) + 1e-9) * slope.abs();
186 if rho0.min(*rho1) <= apex_margin {
187 return Ok(None);
188 }
189 let axis = if slope >= 0.0 {
193 frame.axis
194 } else {
195 frame.axis.scale(-1.0)
196 };
197 let placement = write_placement(writer, frame.origin, axis, frame.x_axis)?;
198 Ok(Some((
199 writer.add(format!(
200 "CONICAL_SURFACE('',#{placement},{},{})",
201 real(*rho0)?,
202 real(slope.abs().atan())?
203 )),
204 flipped,
205 )))
206 }
207 AnalyticSurface::Sphere { frame, radius } => {
208 let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
211 Ok(Some((
212 writer.add(format!(
213 "SPHERICAL_SURFACE('',#{placement},{})",
214 real(*radius)?
215 )),
216 false,
217 )))
218 }
219 AnalyticSurface::Torus {
220 frame,
221 major_radius,
222 minor_radius,
223 } => {
224 let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
225 Ok(Some((
226 writer.add(format!(
227 "TOROIDAL_SURFACE('',#{placement},{},{})",
228 real(*major_radius)?,
229 real(*minor_radius)?
230 )),
231 false,
232 )))
233 }
234 AnalyticSurface::Revolution { .. } => Ok(None),
237 }
238}
239
240struct CircularArc {
245 center: Vec3,
246 axis: Vec3,
247 x_axis: Vec3,
248 radius: f64,
249}
250
251fn curve_scale(curve: &NurbsCurve) -> f64 {
252 curve
253 .control_points
254 .iter()
255 .map(|p| (p.x.abs() / p.w).max(p.y.abs() / p.w).max(p.z.abs() / p.w))
256 .fold(0.0, f64::max)
257}
258
259fn curves_match(a: &NurbsCurve, b: &NurbsCurve, scale: f64) -> bool {
263 if a.degree != b.degree
264 || a.knots.len() != b.knots.len()
265 || a.control_points.len() != b.control_points.len()
266 {
267 return false;
268 }
269 if a.knots
270 .iter()
271 .zip(&b.knots)
272 .any(|(x, y)| (x - y).abs() > 1e-12)
273 {
274 return false;
275 }
276 let tolerance = 1e-9 * scale.max(1.0);
277 a.control_points
278 .iter()
279 .zip(&b.control_points)
280 .all(|(p, q)| {
281 (p.x - q.x).abs() <= tolerance
282 && (p.y - q.y).abs() <= tolerance
283 && (p.z - q.z).abs() <= tolerance
284 && (p.w - q.w).abs() <= 1e-9
285 })
286}
287
288fn recognize_circular_arc(curve: &NurbsCurve) -> Option<CircularArc> {
293 if curve.degree != 2
294 || curve.control_points.len() < 3
295 || curve.control_points.len() % 2 == 0
296 || (curve.control_points.len() - 1) / 2 > 4
297 {
298 return None;
299 }
300 let [t0, t1] = curve.domain().ok()?;
301 let at = |fraction: f64| curve.evaluate(t0 + (t1 - t0) * fraction);
302 let p0 = at(0.0).ok()?;
305 let pa = at(0.35).ok()?;
306 let pb = at(0.7).ok()?;
307 let center = circumcenter(p0, pa, pb)?;
308 let radial = p0.sub(center);
309 let radius = radial.length();
310 let scale = curve_scale(curve);
311 if radius <= 1e-9 * scale.max(1.0) {
312 return None;
313 }
314 let x_axis = radial.scale(1.0 / radius);
315 let axis = radial.cross(pa.sub(center)).normalized().ok()?;
316 let y_axis = axis.cross(x_axis);
317 let p_end = at(1.0).ok()?;
318 let sweep = if p_end.sub(p0).length() <= 1e-9 * (1.0 + radius) {
319 std::f64::consts::TAU
320 } else {
321 let closing = p_end.sub(center);
322 let mut angle = closing.dot(y_axis).atan2(closing.dot(x_axis));
323 if angle < 0.0 {
324 angle += std::f64::consts::TAU;
325 }
326 angle
327 };
328 let rebuilt = make_arc(center, x_axis, y_axis, radius, 0.0, sweep).ok()?;
329 curves_match(curve, &rebuilt, scale).then_some(CircularArc {
330 center,
331 axis,
332 x_axis,
333 radius,
334 })
335}
336
337fn write_analytic_curve(
340 writer: &mut StepWriter,
341 curve: &NurbsCurve,
342) -> Result<Option<usize>, String> {
343 if curve.degree == 1
344 && curve.control_points.len() == 2
345 && curve
346 .control_points
347 .iter()
348 .all(|control| (control.w - 1.0).abs() <= 1e-12)
349 {
350 let start = curve.control_points[0].point()?;
351 let end = curve.control_points[1].point()?;
352 let Ok(direction) = end.sub(start).normalized() else {
353 return Ok(None);
354 };
355 let point = write_point(writer, start)?;
356 let step_direction = write_direction(writer, direction)?;
357 let vector = writer.add(format!(
358 "VECTOR('',#{step_direction},{})",
359 real(end.sub(start).length())?
360 ));
361 return Ok(Some(writer.add(format!("LINE('',#{point},#{vector})"))));
362 }
363 if let Some(arc) = recognize_circular_arc(curve) {
364 let placement = write_placement(writer, arc.center, arc.axis, arc.x_axis)?;
368 return Ok(Some(
369 writer.add(format!("CIRCLE('',#{placement},{})", real(arc.radius)?)),
370 ));
371 }
372 Ok(None)
373}
374
375fn write_curve(writer: &mut StepWriter, curve: &NurbsCurve) -> Result<usize, String> {
376 let points = curve
377 .control_points
378 .iter()
379 .map(|control| write_point(writer, control.point()?))
380 .collect::<Result<Vec<_>, _>>()?;
381 let (knot_values, multiplicities) = knot_runs(&curve.knots);
382 let multiplicities = format!(
383 "({})",
384 multiplicities
385 .iter()
386 .map(usize::to_string)
387 .collect::<Vec<_>>()
388 .join(",")
389 );
390 let knots = format!(
391 "({})",
392 knot_values
393 .iter()
394 .map(|value| real(*value))
395 .collect::<Result<Vec<_>, _>>()?
396 .join(",")
397 );
398 let rational = curve
399 .control_points
400 .iter()
401 .any(|control| (control.w - 1.0).abs() > 1e-12);
402 if !rational {
403 return Ok(writer.add(format!(
404 "B_SPLINE_CURVE_WITH_KNOTS('',{},{},.UNSPECIFIED.,.F.,.F.,{multiplicities},{knots},.UNSPECIFIED.)",
405 curve.degree,
406 id_list(&points),
407 )));
408 }
409 let weights = format!(
410 "({})",
411 curve
412 .control_points
413 .iter()
414 .map(|control| real(control.w))
415 .collect::<Result<Vec<_>, _>>()?
416 .join(",")
417 );
418 Ok(writer.add(format!(
419 "(BOUNDED_CURVE()B_SPLINE_CURVE({},{},.UNSPECIFIED.,.F.,.F.)\
420 B_SPLINE_CURVE_WITH_KNOTS({multiplicities},{knots},.UNSPECIFIED.)\
421 CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE({weights})\
422 REPRESENTATION_ITEM(''))",
423 curve.degree,
424 id_list(&points),
425 )))
426}
427
428fn write_surface(writer: &mut StepWriter, surface: &NurbsSurface) -> Result<usize, String> {
429 let rows = surface
430 .control_points
431 .iter()
432 .map(|row| {
433 row.iter()
434 .map(|control| write_point(writer, control.point()?))
435 .collect::<Result<Vec<_>, _>>()
436 .map(|ids| id_list(&ids))
437 })
438 .collect::<Result<Vec<_>, _>>()?;
439 let grid = format!("({})", rows.join(","));
440 let (u_values, u_multiplicities) = knot_runs(&surface.knots_u);
441 let (v_values, v_multiplicities) = knot_runs(&surface.knots_v);
442 let multiplicities = |values: &[usize]| {
443 format!(
444 "({})",
445 values
446 .iter()
447 .map(usize::to_string)
448 .collect::<Vec<_>>()
449 .join(",")
450 )
451 };
452 let knots = |values: &[f64]| -> Result<String, String> {
453 Ok(format!(
454 "({})",
455 values
456 .iter()
457 .map(|value| real(*value))
458 .collect::<Result<Vec<_>, _>>()?
459 .join(",")
460 ))
461 };
462 let u_mults = multiplicities(&u_multiplicities);
463 let v_mults = multiplicities(&v_multiplicities);
464 let u_knots = knots(&u_values)?;
465 let v_knots = knots(&v_values)?;
466 let rational = surface
467 .control_points
468 .iter()
469 .flatten()
470 .any(|control| (control.w - 1.0).abs() > 1e-12);
471 if !rational {
472 return Ok(writer.add(format!(
473 "B_SPLINE_SURFACE_WITH_KNOTS('',{},{},{grid},.UNSPECIFIED.,.F.,.F.,.F.,\
474 {u_mults},{v_mults},{u_knots},{v_knots},.UNSPECIFIED.)",
475 surface.degree_u, surface.degree_v,
476 )));
477 }
478 let weights = format!(
479 "({})",
480 surface
481 .control_points
482 .iter()
483 .map(|row| {
484 row.iter()
485 .map(|control| real(control.w))
486 .collect::<Result<Vec<_>, _>>()
487 .map(|values| format!("({})", values.join(",")))
488 })
489 .collect::<Result<Vec<_>, _>>()?
490 .join(",")
491 );
492 Ok(writer.add(format!(
493 "(BOUNDED_SURFACE()B_SPLINE_SURFACE({},{},{grid},.UNSPECIFIED.,.F.,.F.,.F.)\
494 B_SPLINE_SURFACE_WITH_KNOTS({u_mults},{v_mults},{u_knots},{v_knots},.UNSPECIFIED.)\
495 GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE({weights})\
496 REPRESENTATION_ITEM('')SURFACE())",
497 surface.degree_u, surface.degree_v,
498 )))
499}
500
501fn write_length_unit(writer: &mut StepWriter, unit: &str) -> Result<usize, String> {
502 let normalized = unit.to_lowercase();
503 if normalized == "meter" || normalized == "metre" {
504 return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.))"));
505 }
506 if normalized == "centimeter" || normalized == "centimetre" {
507 return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))"));
508 }
509 if matches!(normalized.as_str(), "micron" | "micrometer" | "micrometre") {
510 return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MICRO.,.METRE.))"));
511 }
512 if normalized == "inch" || normalized == "foot" {
513 let metre = writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.))");
514 let (factor, name) = if normalized == "inch" {
515 (0.0254, "INCH")
516 } else {
517 (0.3048, "FOOT")
518 };
519 let measure = writer.add(format!(
520 "LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{metre})",
521 real(factor)?
522 ));
523 return Ok(writer.add(format!(
524 "(CONVERSION_BASED_UNIT('{name}',#{measure})LENGTH_UNIT()NAMED_UNIT(*))"
525 )));
526 }
527 Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))"))
528}
529
530fn vertex_for(solid: &BrepSolid, id: u64) -> Result<&VertexRecord, String> {
531 solid
532 .vertices
533 .iter()
534 .find(|vertex| vertex.id == id)
535 .ok_or_else(|| format!("export_step: missing vertex {id}"))
536}
537
538fn edge_for(solid: &BrepSolid, id: u64) -> Result<&EdgeRecord, String> {
539 solid
540 .edges
541 .iter()
542 .find(|edge| edge.id == id)
543 .ok_or_else(|| format!("export_step: missing edge {id}"))
544}
545
546fn surface_key(face: &FaceRecord) -> usize {
547 face as *const FaceRecord as usize
548}
549
550pub fn export_step(
552 solids: &[BrepSolid],
553 name: &str,
554 unit: &str,
555 timestamp: &str,
556) -> Result<String, String> {
557 if solids.is_empty() {
558 return Err("export_step: at least one solid is required".into());
559 }
560 for solid in solids {
561 let policy = KernelTolerances::for_solid(solid, 1e-7);
562 let issues = solid.validate_with_tolerances(&KernelTolerances {
563 pcurve_consistency: policy.export_knit,
564 ..policy
565 });
566 if !issues.is_empty() {
567 return Err(format!("export_step: invalid solid: {issues:?}"));
568 }
569 }
570 let mut writer = StepWriter::default();
571 let safe_name = step_string(name);
572 let application = writer.add("APPLICATION_CONTEXT('automotive design')");
573 writer.add(format!(
574 "APPLICATION_PROTOCOL_DEFINITION('','automotive_design',2010,#{application})"
575 ));
576 let product_context = writer.add(format!("PRODUCT_CONTEXT('',#{application},'mechanical')"));
577 let product = writer.add(format!(
578 "PRODUCT('{safe_name}','{safe_name}','',(#{product_context}))"
579 ));
580 let formation = writer.add(format!("PRODUCT_DEFINITION_FORMATION('','',#{product})"));
581 let definition_context = writer.add(format!(
582 "PRODUCT_DEFINITION_CONTEXT('part definition',#{application},'design')"
583 ));
584 let definition = writer.add(format!(
585 "PRODUCT_DEFINITION('design','',#{formation},#{definition_context})"
586 ));
587 let product_shape = writer.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{definition})"));
588 let length_unit = write_length_unit(&mut writer, unit)?;
589 let angle_unit = writer.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))");
590 let solid_angle_unit = writer.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())");
591 let uncertainty = writer.add(format!(
592 "UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{length_unit},'distance_accuracy_value','')"
593 ));
594 let geometry_context = writer.add(format!(
595 "(GEOMETRIC_REPRESENTATION_CONTEXT(3)\
596 GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{uncertainty}))\
597 GLOBAL_UNIT_ASSIGNED_CONTEXT((#{length_unit},#{angle_unit},#{solid_angle_unit}))\
598 REPRESENTATION_CONTEXT('',''))"
599 ));
600 let origin = write_point(&mut writer, Vec3::default())?;
601 let direction_z = writer.add("DIRECTION('',(0.,0.,1.))");
602 let direction_x = writer.add("DIRECTION('',(1.,0.,0.))");
603 let axis = writer.add(format!(
604 "AXIS2_PLACEMENT_3D('',#{origin},#{direction_z},#{direction_x})"
605 ));
606
607 let mut solid_ids = Vec::new();
608 for solid in solids {
609 let mut vertex_ids = HashMap::<u64, usize>::default();
610 let mut edge_ids = HashMap::<u64, usize>::default();
611 let mut surface_ids = HashMap::<usize, (usize, bool)>::default();
612 for shell in &solid.shells {
613 let mut face_ids = Vec::new();
614 for face in &shell.faces {
615 let mut bound_ids = Vec::new();
616 for (loop_index, loop_record) in face.loops.iter().enumerate() {
617 let mut oriented_edges = Vec::new();
618 for coedge in &loop_record.coedges {
619 let edge = edge_for(solid, coedge.edge_id)?;
620 if edge.degenerate {
621 continue;
622 }
623 let edge_id = if let Some(id) = edge_ids.get(&edge.id) {
624 *id
625 } else {
626 let subcurve = edge_subcurve(edge)?;
627 let curve = match write_analytic_curve(&mut writer, &subcurve)? {
628 Some(id) => id,
629 None => write_curve(&mut writer, &subcurve)?,
630 };
631 let mut vertex_id =
632 |id: u64, writer: &mut StepWriter| -> Result<usize, String> {
633 if let Some(step_id) = vertex_ids.get(&id) {
634 return Ok(*step_id);
635 }
636 let point = write_point(writer, vertex_for(solid, id)?.point)?;
637 let step_id = writer.add(format!("VERTEX_POINT('',#{point})"));
638 vertex_ids.insert(id, step_id);
639 Ok(step_id)
640 };
641 let start = vertex_id(edge.start_vertex_id, &mut writer)?;
642 let end = vertex_id(edge.end_vertex_id, &mut writer)?;
643 let step_id =
644 writer.add(format!("EDGE_CURVE('',#{start},#{end},#{curve},.T.)"));
645 edge_ids.insert(edge.id, step_id);
646 step_id
647 };
648 let orientation = if coedge.forward { ".T." } else { ".F." };
649 oriented_edges.push(
650 writer.add(format!("ORIENTED_EDGE('',*,*,#{edge_id},{orientation})")),
651 );
652 }
653 if oriented_edges.is_empty() {
654 continue;
655 }
656 let edge_loop =
657 writer.add(format!("EDGE_LOOP('',{})", id_list(&oriented_edges)));
658 let kind = if loop_index == 0 {
659 "FACE_OUTER_BOUND"
660 } else {
661 "FACE_BOUND"
662 };
663 bound_ids.push(writer.add(format!("{kind}('',#{edge_loop},.T.)")));
664 }
665 let key = surface_key(face);
666 let (surface, flipped) = if let Some(entry) = surface_ids.get(&key) {
667 *entry
668 } else {
669 let entry = match write_analytic_surface(&mut writer, &face.surface)? {
670 Some(pair) => pair,
671 None => (write_surface(&mut writer, &face.surface)?, false),
672 };
673 surface_ids.insert(key, entry);
674 entry
675 };
676 let sense = if face.same_sense != flipped {
680 ".T."
681 } else {
682 ".F."
683 };
684 face_ids.push(writer.add(format!(
685 "ADVANCED_FACE('',{},#{surface},{sense})",
686 id_list(&bound_ids)
687 )));
688 }
689 let closed_shell = writer.add(format!("CLOSED_SHELL('',{})", id_list(&face_ids)));
690 solid_ids.push(writer.add(format!(
691 "MANIFOLD_SOLID_BREP('{safe_name}',#{closed_shell})"
692 )));
693 }
694 }
695 let mut items = vec![axis];
696 items.extend(&solid_ids);
697 let representation = writer.add(format!(
698 "ADVANCED_BREP_SHAPE_REPRESENTATION('',{},#{geometry_context})",
699 id_list(&items)
700 ));
701 writer.add(format!(
702 "SHAPE_DEFINITION_REPRESENTATION(#{product_shape},#{representation})"
703 ));
704 let safe_timestamp = step_string(timestamp);
705 let output = [
706 "ISO-10303-21;".to_string(),
707 "HEADER;".to_string(),
708 "FILE_DESCRIPTION((''),'2;1');".to_string(),
709 format!(
710 "FILE_NAME('{safe_name}.step','{safe_timestamp}',(''),(''),'brep-kernel-rs','brep-kernel-rs','');"
711 ),
712 "FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));".to_string(),
713 "ENDSEC;".to_string(),
714 "DATA;".to_string(),
715 writer.data(),
716 "ENDSEC;".to_string(),
717 "END-ISO-10303-21;".to_string(),
718 String::new(),
719 ]
720 .join("\n");
721 let manifold_issues = audit_step_manifold(&output);
722 if !manifold_issues.is_empty() {
723 return Err(format!(
724 "export_step: emitted AP214 manifold audit failed: {}",
725 manifold_issues.join("; ")
726 ));
727 }
728 Ok(output)
729}
730
731pub fn audit_step_manifold(step: &str) -> Vec<String> {
736 let marker = "ORIENTED_EDGE('',*,*,#";
737 let mut uses = HashMap::<u64, Vec<bool>>::default();
738 for line in step.lines() {
739 let Some(offset) = line.find(marker) else {
740 continue;
741 };
742 let rest = &line[offset + marker.len()..];
743 let digits = rest
744 .chars()
745 .take_while(|character| character.is_ascii_digit())
746 .collect::<String>();
747 let Ok(edge_id) = digits.parse::<u64>() else {
748 continue;
749 };
750 let suffix = &rest[digits.len()..];
751 let sense = suffix.starts_with(",.T.");
752 uses.entry(edge_id).or_default().push(sense);
753 }
754 let mut issues = uses
755 .into_iter()
756 .filter_map(|(edge, senses)| {
757 (senses.len() != 2 || senses[0] == senses[1]).then(|| {
758 format!(
759 "EDGE_CURVE #{edge} has {} uses with senses {:?}",
760 senses.len(),
761 senses
762 )
763 })
764 })
765 .collect::<Vec<_>>();
766 issues.sort();
767 issues
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 use crate::{
774 boolean_operation, import_step, make_box_brep, make_cone_brep, make_cylinder_brep,
775 make_cylinder_surface, make_sphere_brep, make_torus_brep, solid_mass_properties,
776 BooleanOperation, BooleanOptions,
777 };
778
779 #[test]
780 fn box_step_contains_exact_manifold_topology() {
781 let box_solid = make_box_brep(Vec3::default(), 2.0, 3.0, 4.0).unwrap();
782 let step = export_step(&[box_solid], "box", "millimeter", "2026-07-27T00:00:00").unwrap();
783 assert!(step.starts_with("ISO-10303-21;\nHEADER;"));
784 assert!(audit_step_manifold(&step).is_empty());
785 assert!(step.contains("MANIFOLD_SOLID_BREP('box'"));
786 assert_eq!(step.matches("ADVANCED_FACE(").count(), 6);
787 assert_eq!(step.matches("EDGE_CURVE(").count(), 12);
788 assert!(step.ends_with("END-ISO-10303-21;\n"));
789 }
790
791 #[test]
792 fn step_manifold_audit_rejects_single_and_same_sense_uses() {
793 let single = "#1=ORIENTED_EDGE('',*,*,#9,.T.);";
794 assert_eq!(audit_step_manifold(single).len(), 1);
795 let same = "#1=ORIENTED_EDGE('',*,*,#9,.T.);\n\
796 #2=ORIENTED_EDGE('',*,*,#9,.T.);";
797 assert_eq!(audit_step_manifold(same).len(), 1);
798 let good = "#1=ORIENTED_EDGE('',*,*,#9,.T.);\n\
799 #2=ORIENTED_EDGE('',*,*,#9,.F.);";
800 assert!(audit_step_manifold(good).is_empty());
801 }
802
803 #[test]
804 fn unrecognized_surfaces_and_curves_still_write_rational_complex_entities() {
805 let mut writer = StepWriter::default();
808 let cylinder =
809 make_cylinder_surface(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
810 write_surface(&mut writer, &cylinder).unwrap();
811 let split_arc = make_arc(
812 Vec3::default(),
813 Vec3::new(1.0, 0.0, 0.0),
814 Vec3::new(0.0, 1.0, 0.0),
815 2.0,
816 0.0,
817 std::f64::consts::TAU,
818 )
819 .unwrap()
820 .split(0.37)
821 .unwrap()
822 .1;
823 assert!(
824 recognize_circular_arc(&split_arc).is_none(),
825 "a split subrange is not the pristine make_arc net"
826 );
827 assert!(write_analytic_curve(&mut writer, &split_arc)
828 .unwrap()
829 .is_none());
830 write_curve(&mut writer, &split_arc).unwrap();
831 let data = writer.data();
832 assert!(data.contains("RATIONAL_B_SPLINE_SURFACE"));
833 assert!(data.contains("RATIONAL_B_SPLINE_CURVE"));
834 }
835
836 fn assert_analytic_round_trip(
840 label: &str,
841 original: &BrepSolid,
842 expected_markers: &[&str],
843 forbid_nurbs: bool,
844 ) -> BrepSolid {
845 let step = export_step(std::slice::from_ref(original), label, "millimeter", "fixed")
846 .expect("export");
847 for marker in expected_markers {
848 assert!(step.contains(marker), "{label}: missing {marker}");
849 }
850 if forbid_nurbs {
851 assert!(
852 !step.contains("B_SPLINE"),
853 "{label}: expected a fully analytic export"
854 );
855 }
856 assert!(audit_step_manifold(&step).is_empty(), "{label}: audit");
857 let imported = import_step(&step).expect("import");
858 assert_eq!(imported.len(), 1, "{label}: one solid");
859 let solid = imported.into_iter().next().unwrap();
860 assert!(
861 solid.validate().is_empty(),
862 "{label}: imported solid invalid: {:?}",
863 solid.validate()
864 );
865 let original_volume = solid_mass_properties(original).unwrap().volume;
866 let volume = solid_mass_properties(&solid).unwrap().volume;
867 let relative = ((volume - original_volume) / original_volume).abs();
868 assert!(
869 relative < 1e-6,
870 "{label}: volume {volume} vs {original_volume} (rel {relative:.3e})"
871 );
872 for shell in &solid.shells {
873 for face in &shell.faces {
874 assert!(
875 face.surface.analytic().is_some(),
876 "{label}: imported face {} did not re-recognize as analytic",
877 face.id
878 );
879 }
880 }
881 solid
882 }
883
884 #[test]
885 fn box_round_trips_through_plane_and_line_entities() {
886 let solid = make_box_brep(Vec3::new(-1.0, 0.5, 2.0), 2.0, 3.0, 4.0).unwrap();
887 let step = export_step(std::slice::from_ref(&solid), "box", "millimeter", "fixed").unwrap();
888 assert_eq!(step.matches("PLANE(").count(), 6);
889 assert_eq!(step.matches("LINE(").count(), 12);
890 assert_analytic_round_trip("box", &solid, &["PLANE(", "LINE("], true);
891 }
892
893 #[test]
894 fn cylinder_round_trips_through_analytic_entities() {
895 let solid = make_cylinder_brep(
896 Vec3::new(1.0, -2.0, 0.5),
897 Vec3::new(0.0, 0.0, 1.0),
898 2.0,
899 5.0,
900 )
901 .unwrap();
902 assert_analytic_round_trip(
903 "cylinder",
904 &solid,
905 &[
906 "CYLINDRICAL_SURFACE(",
907 "PLANE(",
908 "CIRCLE(",
909 "LINE(",
910 "VECTOR(",
911 ],
912 true,
913 );
914 }
915
916 #[test]
917 fn cylinder_export_keeps_unit_conversion_entities() {
918 let cylinder =
919 make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
920 let step = export_step(&[cylinder], "cylinder", "inch", "fixed").unwrap();
921 assert!(step.contains("CYLINDRICAL_SURFACE("));
922 assert!(step.contains("CONVERSION_BASED_UNIT('INCH'"));
923 }
924
925 #[test]
926 fn frustum_round_trips_through_conical_surface() {
927 let solid = make_cone_brep(
928 Vec3::new(0.5, 0.5, -1.0),
929 Vec3::new(0.0, 0.0, 1.0),
930 3.0,
931 1.5,
932 5.0,
933 )
934 .unwrap();
935 assert_analytic_round_trip("frustum", &solid, &["CONICAL_SURFACE(", "PLANE("], true);
936 }
937
938 #[test]
939 fn pointed_cone_wall_stays_nurbs_but_caps_and_rim_export_analytic() {
940 let solid =
945 make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 0.0, 5.0).unwrap();
946 let step =
947 export_step(std::slice::from_ref(&solid), "cone", "millimeter", "fixed").unwrap();
948 assert!(!step.contains("CONICAL_SURFACE("));
949 assert!(step.contains("B_SPLINE_SURFACE"));
950 assert!(step.contains("PLANE("));
951 assert!(step.contains("CIRCLE("));
952 let imported = import_step(&step).expect("import");
953 let volume = solid_mass_properties(&imported[0]).unwrap().volume;
954 let expected = solid_mass_properties(&solid).unwrap().volume;
955 assert!(((volume - expected) / expected).abs() < 1e-6);
956 }
957
958 #[test]
959 fn sphere_round_trips_through_spherical_surface() {
960 let solid =
961 make_sphere_brep(Vec3::new(2.0, 1.0, -1.0), 3.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
962 assert_analytic_round_trip("sphere", &solid, &["SPHERICAL_SURFACE(", "CIRCLE("], true);
963 }
964
965 #[test]
966 fn torus_round_trips_through_toroidal_surface() {
967 let solid =
968 make_torus_brep(Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, 1.0), 5.0, 1.5).unwrap();
969 assert_analytic_round_trip("torus", &solid, &["TOROIDAL_SURFACE(", "CIRCLE("], true);
970 }
971
972 #[test]
973 fn box_minus_cylinder_round_trips_with_analytic_entities() {
974 let block = make_box_brep(Vec3::new(-3.0, -3.0, 0.0), 6.0, 6.0, 4.0).unwrap();
975 let drill = make_cylinder_brep(
976 Vec3::new(0.0, 0.0, -1.0),
977 Vec3::new(0.0, 0.0, 1.0),
978 1.5,
979 6.0,
980 )
981 .unwrap();
982 let cut = boolean_operation(
983 &block,
984 &drill,
985 BooleanOperation::Subtract,
986 &BooleanOptions::default(),
987 )
988 .unwrap();
989 let solid = assert_analytic_round_trip(
992 "box_minus_cyl",
993 &cut,
994 &["CYLINDRICAL_SURFACE(", "PLANE("],
995 false,
996 );
997 assert_eq!(solid.genus, 1, "through-hole genus survives the round trip");
998 }
999}