Skip to main content

brep_kernel/io/
step.rs

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    // Analytic frame axes come from cross products that can round to -0.0;
15    // normalize so directions never print a negative zero component.
16    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
125/// Emit the analytic AP214 surface entity (PLANE / CYLINDRICAL_SURFACE /
126/// CONICAL_SURFACE / SPHERICAL_SURFACE / TOROIDAL_SURFACE) for a recognized
127/// carrier, or `None` when the surface must stay a B-spline. The second value
128/// reports whether the STEP-standard orientation of the emitted entity (plane
129/// normal along the placement axis; revolution normal outward) is the REVERSE
130/// of the stored NURBS orientation, so ADVANCED_FACE can invert `same_sense`
131/// and the face normal survives the round trip.
132fn 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            // STEP planes are unbounded; the importer re-sizes the patch from
147            // the face's edges, so only origin/normal/ref matter.
148            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            // The recognizer allows height < 0 (descending generatrix), whose
162            // normal is the reverse of the standard outward convention the
163            // importer reconstructs; report that so the face sense compensates.
164            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            // Cone. The importer re-sizes the carrier from edge samples with a
180            // 1e-4-scaled axial margin and clamps a negative extended-end
181            // radius to zero — which BENDS the rebuilt slope when the apex sits
182            // at an end of the face's axial range. Keep apex-touching cones as
183            // exact NURBS instead of exporting a distorted carrier.
184            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            // Orient the placement axis so the radius grows along +axis: STEP
190            // semi-angles are positive. Radius at the placement origin stays
191            // rho0 either way because the origin is on-axis at the v = 0 base.
192            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            // Recognition template and importer reconstruction share the same
209            // south-to-north meridian construction, so the rebuild is exact.
210            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        // No SURFACE_OF_REVOLUTION reader exists yet; general revolutions keep
235        // their exact NURBS form.
236        AnalyticSurface::Revolution { .. } => Ok(None),
237    }
238}
239
240/// A curve recognized as an exact `make_arc` product: a circular arc of
241/// `radius` about `axis`, starting at angle 0 on `x_axis` and travelling
242/// counterclockwise through `sweep` — exactly the CIRCLE parameterization the
243/// importer trims between the edge's vertices.
244struct 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
259/// Homogeneous-net equality to a scale-relative tolerance (the same
260/// reconstruction contract analytic_surface.rs uses): matching nets mean the
261/// curves are the SAME exact rational arc, not merely close.
262fn 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
288/// Recognition by exact reconstruction: extract a candidate circle from three
289/// curve points, rebuild it with `make_arc`, and demand the identical net.
290/// Split subranges of a circle (whose knots are no longer the pristine
291/// make_arc pattern) are rejected and honestly stay NURBS.
292fn 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    // Three points at < 74% of the sweep apart, so consecutive pairs subtend
303    // less than pi and the cross product below gives the travel direction.
304    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
337/// Emit LINE or CIRCLE for a recognized analytic edge curve (already oriented
338/// start-to-end by `edge_subcurve`), or `None` for the B-spline fallback.
339fn 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        // ref_direction points at the edge's start vertex and the arc runs
365        // counterclockwise about the axis, so the importer's vertex-trimmed
366        // CCW rebuild reproduces the same directed curve with sense .T.
367        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
550/// Serialize exact NURBS BREP topology as an AP214 STEP Part 21 document.
551pub 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                // `flipped` analytic entities are written with the reverse of
677                // the stored NURBS orientation, so invert the flag to keep the
678                // face normal identical through the round trip.
679                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
731/// Audit the serialized entity graph rather than assuming that valid
732/// in-memory topology was necessarily written correctly.  Every EDGE_CURVE
733/// in a closed shell must have exactly two ORIENTED_EDGE users with opposite
734/// senses.
735pub 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        // The all-NURBS fallback writers must stay intact for carriers no
806        // analytic entity covers (general revolutions, split arc subranges).
807        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    /// Export → assert the analytic entities appear (and, when the solid is
837    /// fully analytic, that NO B-spline entity remains) → import → validate,
838    /// match volume to 1e-6, and re-recognize every face's carrier.
839    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        // The importer's axial cover margin clamps a negative apex-end radius
941        // to zero, bending the rebuilt slope; an apex-touching CONICAL export
942        // would round-trip with ~1e-4 relative volume error, so the wall must
943        // honestly stay NURBS while the cap plane and rim circle go analytic.
944        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        // Boolean-produced edges may be split subranges (NURBS fallback), so
990        // only the surface entities are required to be analytic here.
991        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}