Skip to main content

brep_kernel/io/
step.rs

1use crate::analytic_surface::{circumcenter, AnalyticSurface};
2use crate::topology::{BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, VertexRecord};
3use crate::{make_arc, KernelTolerances, NurbsCurve, NurbsSurface, Vec3};
4use rustc_hash::FxHashMap as HashMap;
5
6#[path = "step/pcurve.rs"]
7mod pcurve;
8use pcurve::{build_pcurve, EmittedCurve, EmittedFrame, EmittedSurface, Pcurve2d};
9#[path = "step/pmi.rs"]
10pub(crate) mod pmi;
11pub use pmi::StepPmi;
12#[path = "step/assembly.rs"]
13pub mod assembly;
14pub use assembly::{
15    export_step_assembly, export_step_assembly_report, StepAssemblyExport, StepExportOccurrence,
16    StepExportProduct,
17};
18#[path = "step/export_tree.rs"]
19mod export_tree;
20pub use export_tree::assembly_export_tree;
21
22pub use crate::step_matrix::Mat4;
23pub(crate) use crate::step_matrix::{mat4_mul, MAT4_IDENTITY};
24
25pub(crate) fn transform_point(matrix: &Mat4, point: Vec3) -> Vec3 {
26    crate::AffineTransform {
27        elements: *matrix,
28    }
29    .point(point)
30}
31
32pub(crate) fn step_string(value: &str) -> String {
33    value.replace('\'', "''")
34}
35
36pub(crate) fn real(value: f64) -> Result<String, String> {
37    if !value.is_finite() {
38        return Err(format!("export_step: non-finite number {value}"));
39    }
40    // Analytic frame axes come from cross products that can round to -0.0;
41    // normalize so directions never print a negative zero component.
42    if value == 0.0 {
43        return Ok("0.".into());
44    }
45    if value.fract() == 0.0 && value.abs() < 1e15 {
46        return Ok(format!("{value:.0}."));
47    }
48    let mut output = format!("{value:.15}");
49    while output.ends_with('0') {
50        output.pop();
51    }
52    if output.ends_with('.') {
53        output.push('0');
54    }
55    if output == "-0.0" {
56        output = "0.0".into();
57    }
58    Ok(output)
59}
60
61fn knot_runs(knots: &[f64]) -> (Vec<f64>, Vec<usize>) {
62    let mut values = Vec::new();
63    let mut multiplicities = Vec::new();
64    for &knot in knots {
65        if values
66            .last()
67            .is_some_and(|previous: &f64| (*previous - knot).abs() <= 1e-12)
68        {
69            *multiplicities.last_mut().unwrap() += 1;
70        } else {
71            values.push(knot);
72            multiplicities.push(1);
73        }
74    }
75    (values, multiplicities)
76}
77
78pub(crate) fn edge_subcurve(edge: &EdgeRecord) -> Result<NurbsCurve, String> {
79    let [start, end] = edge.curve.domain()?;
80    let epsilon = (1e-9 * (end - start)).max(2e-9);
81    let mut curve = edge.curve.clone();
82    if edge.t0 > start + epsilon && edge.t0 < end - epsilon {
83        curve = curve.split(edge.t0)?.1;
84    }
85    let domain = curve.domain()?;
86    if edge.t1 < domain[1] - epsilon && edge.t1 > domain[0] + epsilon {
87        curve = curve.split(edge.t1)?.0;
88    }
89    Ok(curve)
90}
91
92#[derive(Default)]
93pub(crate) struct StepWriter {
94    lines: Vec<String>,
95}
96
97impl StepWriter {
98    pub(crate) fn add(&mut self, body: impl Into<String>) -> usize {
99        let id = self.lines.len() + 1;
100        self.lines.push(format!("#{id}={};", body.into()));
101        id
102    }
103
104    fn data(&self) -> String {
105        self.lines.join("\n")
106    }
107}
108
109pub(crate) fn write_point(writer: &mut StepWriter, point: Vec3) -> Result<usize, String> {
110    Ok(writer.add(format!(
111        "CARTESIAN_POINT('',({},{},{}))",
112        real(point.x)?,
113        real(point.y)?,
114        real(point.z)?
115    )))
116}
117
118pub(crate) fn id_list(ids: &[usize]) -> String {
119    format!(
120        "({})",
121        ids.iter()
122            .map(|id| format!("#{id}"))
123            .collect::<Vec<_>>()
124            .join(",")
125    )
126}
127
128pub(crate) fn write_direction(writer: &mut StepWriter, direction: Vec3) -> Result<usize, String> {
129    Ok(writer.add(format!(
130        "DIRECTION('',({},{},{}))",
131        real(direction.x)?,
132        real(direction.y)?,
133        real(direction.z)?
134    )))
135}
136
137pub(crate) fn write_placement(
138    writer: &mut StepWriter,
139    origin: Vec3,
140    axis: Vec3,
141    ref_direction: Vec3,
142) -> Result<usize, String> {
143    let origin = write_point(writer, origin)?;
144    let axis = write_direction(writer, axis)?;
145    let ref_direction = write_direction(writer, ref_direction)?;
146    Ok(writer.add(format!(
147        "AXIS2_PLACEMENT_3D('',#{origin},#{axis},#{ref_direction})"
148    )))
149}
150
151/// Emit the analytic AP242 surface entity (PLANE / CYLINDRICAL_SURFACE /
152/// CONICAL_SURFACE / SPHERICAL_SURFACE / TOROIDAL_SURFACE) for a recognized
153/// carrier, or `None` when the surface must stay a B-spline. The second value
154/// reports whether the STEP-standard orientation of the emitted entity (plane
155/// normal along the placement axis; revolution normal outward) is the REVERSE
156/// of the stored NURBS orientation, so ADVANCED_FACE can invert `same_sense`
157/// and the face normal survives the round trip. The third describes the
158/// emitted entity's own (u,v) parameterization for the pcurve writer.
159fn write_analytic_surface(
160    writer: &mut StepWriter,
161    surface: &NurbsSurface,
162) -> Result<Option<(usize, bool, EmittedSurface)>, String> {
163    let Some(analytic) = surface.analytic() else {
164        return Ok(None);
165    };
166    match analytic {
167        AnalyticSurface::Plane {
168            origin,
169            u_dir,
170            v_dir,
171            ..
172        } => {
173            // STEP planes are unbounded; the importer re-sizes the patch from
174            // the face's edges, so only origin/normal/ref matter.
175            let (Ok(normal), Ok(x_axis)) = (u_dir.cross(*v_dir).normalized(), u_dir.normalized())
176            else {
177                return Ok(None);
178            };
179            let placement = write_placement(writer, *origin, normal, x_axis)?;
180            // The reader derives the plane's second parameter axis as
181            // normal × ref_direction, so S(x,y) = origin + x·x̂ + y·ŷ with
182            // those exact vectors — an orthonormal frame regardless of how the
183            // stored patch scaled or sheared its own u_dir/v_dir.
184            Ok(Some((
185                writer.add(format!("PLANE('',#{placement})")),
186                false,
187                EmittedSurface::Plane {
188                    origin: *origin,
189                    x_axis,
190                    y_axis: normal.cross(x_axis),
191                },
192            )))
193        }
194        AnalyticSurface::RuledRevolution {
195            frame,
196            rho0,
197            rho1,
198            height,
199        } => {
200            // The recognizer allows height < 0 (descending generatrix), whose
201            // normal is the reverse of the standard outward convention the
202            // importer reconstructs; report that so the face sense compensates.
203            let flipped = *height < 0.0;
204            let radius_scale = 1.0 + rho0.abs().max(rho1.abs());
205            if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
206                if *rho0 <= 0.0 {
207                    return Ok(None);
208                }
209                let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
210                return Ok(Some((
211                    writer.add(format!(
212                        "CYLINDRICAL_SURFACE('',#{placement},{})",
213                        real(*rho0)?
214                    )),
215                    flipped,
216                    EmittedSurface::Cylinder {
217                        frame: EmittedFrame {
218                            origin: frame.origin,
219                            x_axis: frame.x_axis,
220                            y_axis: frame.y_axis,
221                            axis: frame.axis,
222                            azimuth_sign: 1.0,
223                        },
224                        radius: *rho0,
225                    },
226                )));
227            }
228            // Cone. The importer re-sizes the carrier from edge samples with a
229            // 1e-4-scaled axial margin and clamps a negative extended-end
230            // radius to zero — which BENDS the rebuilt slope when the apex sits
231            // at an end of the face's axial range. Keep apex-touching cones as
232            // exact NURBS instead of exporting a distorted carrier.
233            let slope = (rho1 - rho0) / height;
234            let apex_margin = 2.0 * (1e-4 * height.abs().max(1.0) + 1e-9) * slope.abs();
235            if rho0.min(*rho1) <= apex_margin {
236                return Ok(None);
237            }
238            // Orient the placement axis so the radius grows along +axis: STEP
239            // semi-angles are positive. Radius at the placement origin stays
240            // rho0 either way because the origin is on-axis at the v = 0 base.
241            let axis = if slope >= 0.0 {
242                frame.axis
243            } else {
244                frame.axis.scale(-1.0)
245            };
246            let placement = write_placement(writer, frame.origin, axis, frame.x_axis)?;
247            Ok(Some((
248                writer.add(format!(
249                    "CONICAL_SURFACE('',#{placement},{},{})",
250                    real(*rho0)?,
251                    real(slope.abs().atan())?
252                )),
253                flipped,
254                EmittedSurface::Cone {
255                    frame: EmittedFrame {
256                        origin: frame.origin,
257                        x_axis: frame.x_axis,
258                        // Reversing the placement axis reverses the derived
259                        // second axis with it, so the emitted azimuth runs
260                        // opposite the stored carrier's u.
261                        y_axis: axis.cross(frame.x_axis),
262                        axis,
263                        azimuth_sign: if slope >= 0.0 { 1.0 } else { -1.0 },
264                    },
265                    radius: *rho0,
266                    semi_angle: slope.abs().atan(),
267                },
268            )))
269        }
270        AnalyticSurface::Sphere { frame, radius } => {
271            // Recognition template and importer reconstruction share the same
272            // south-to-north meridian construction, so the rebuild is exact.
273            let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
274            Ok(Some((
275                writer.add(format!(
276                    "SPHERICAL_SURFACE('',#{placement},{})",
277                    real(*radius)?
278                )),
279                false,
280                EmittedSurface::Sphere {
281                    frame: EmittedFrame {
282                        origin: frame.origin,
283                        x_axis: frame.x_axis,
284                        y_axis: frame.y_axis,
285                        axis: frame.axis,
286                        azimuth_sign: 1.0,
287                    },
288                    radius: *radius,
289                },
290            )))
291        }
292        AnalyticSurface::Torus {
293            frame,
294            major_radius,
295            minor_radius,
296        } => {
297            let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
298            Ok(Some((
299                writer.add(format!(
300                    "TOROIDAL_SURFACE('',#{placement},{},{})",
301                    real(*major_radius)?,
302                    real(*minor_radius)?
303                )),
304                false,
305                EmittedSurface::Torus {
306                    frame: EmittedFrame {
307                        origin: frame.origin,
308                        x_axis: frame.x_axis,
309                        y_axis: frame.y_axis,
310                        axis: frame.axis,
311                        azimuth_sign: 1.0,
312                    },
313                    major_radius: *major_radius,
314                    minor_radius: *minor_radius,
315                },
316            )))
317        }
318        // No SURFACE_OF_REVOLUTION reader exists yet; general revolutions keep
319        // their exact NURBS form.
320        AnalyticSurface::Revolution { .. } => Ok(None),
321    }
322}
323
324/// A curve recognized as an exact `make_arc` product: a circular arc of
325/// `radius` about `axis`, starting at angle 0 on `x_axis` and travelling
326/// counterclockwise through `sweep` — exactly the CIRCLE parameterization the
327/// importer trims between the edge's vertices.
328struct CircularArc {
329    center: Vec3,
330    axis: Vec3,
331    x_axis: Vec3,
332    /// axis × x_axis — the second placement axis a STEP reader derives, so the
333    /// emitted parameterization is C(a) = center + r(cos a·x̂ + sin a·ŷ).
334    y_axis: Vec3,
335    radius: f64,
336    /// Total swept angle: the emitted entity's parameter range is [0, sweep].
337    sweep: f64,
338    /// Rational-quadratic span count of the kernel arc this was recognized
339    /// from — the bridge from the emitted ANGLE back to the kernel's own
340    /// parameter, which a fitted pcurve on a B-spline carrier needs.
341    spans: usize,
342}
343
344fn curve_scale(curve: &NurbsCurve) -> f64 {
345    curve
346        .control_points
347        .iter()
348        .map(|p| (p.x.abs() / p.w).max(p.y.abs() / p.w).max(p.z.abs() / p.w))
349        .fold(0.0, f64::max)
350}
351
352/// Homogeneous-net equality to a scale-relative tolerance (the same
353/// reconstruction contract analytic_surface.rs uses): matching nets mean the
354/// curves are the SAME exact rational arc, not merely close.
355fn curves_match(a: &NurbsCurve, b: &NurbsCurve, scale: f64) -> bool {
356    if a.degree != b.degree
357        || a.knots.len() != b.knots.len()
358        || a.control_points.len() != b.control_points.len()
359    {
360        return false;
361    }
362    if a.knots
363        .iter()
364        .zip(&b.knots)
365        .any(|(x, y)| (x - y).abs() > 1e-12)
366    {
367        return false;
368    }
369    let tolerance = 1e-9 * scale.max(1.0);
370    a.control_points
371        .iter()
372        .zip(&b.control_points)
373        .all(|(p, q)| {
374            (p.x - q.x).abs() <= tolerance
375                && (p.y - q.y).abs() <= tolerance
376                && (p.z - q.z).abs() <= tolerance
377                && (p.w - q.w).abs() <= 1e-9
378        })
379}
380
381/// Recognition by exact reconstruction: extract a candidate circle from three
382/// curve points, rebuild it with `make_arc`, and demand the identical net.
383/// Split subranges of a circle (whose knots are no longer the pristine
384/// make_arc pattern) are rejected and honestly stay NURBS.
385fn recognize_circular_arc(curve: &NurbsCurve) -> Option<CircularArc> {
386    if curve.degree != 2
387        || curve.control_points.len() < 3
388        || curve.control_points.len() % 2 == 0
389        || (curve.control_points.len() - 1) / 2 > 4
390    {
391        return None;
392    }
393    let [t0, t1] = curve.domain().ok()?;
394    let at = |fraction: f64| curve.evaluate(t0 + (t1 - t0) * fraction);
395    // Three points at < 74% of the sweep apart, so consecutive pairs subtend
396    // less than pi and the cross product below gives the travel direction.
397    let p0 = at(0.0).ok()?;
398    let pa = at(0.35).ok()?;
399    let pb = at(0.7).ok()?;
400    let center = circumcenter(p0, pa, pb)?;
401    let radial = p0.sub(center);
402    let radius = radial.length();
403    let scale = curve_scale(curve);
404    if radius <= 1e-9 * scale.max(1.0) {
405        return None;
406    }
407    let x_axis = radial.scale(1.0 / radius);
408    let axis = radial.cross(pa.sub(center)).normalized().ok()?;
409    let y_axis = axis.cross(x_axis);
410    let p_end = at(1.0).ok()?;
411    let sweep = if p_end.sub(p0).length() <= 1e-9 * (1.0 + radius) {
412        std::f64::consts::TAU
413    } else {
414        let closing = p_end.sub(center);
415        let mut angle = closing.dot(y_axis).atan2(closing.dot(x_axis));
416        if angle < 0.0 {
417            angle += std::f64::consts::TAU;
418        }
419        angle
420    };
421    let rebuilt = make_arc(center, x_axis, y_axis, radius, 0.0, sweep).ok()?;
422    curves_match(curve, &rebuilt, scale).then_some(CircularArc {
423        center,
424        axis,
425        x_axis,
426        y_axis,
427        radius,
428        sweep,
429        spans: (curve.control_points.len() - 1) / 2,
430    })
431}
432
433/// Emit LINE or CIRCLE for a recognized analytic edge curve (already oriented
434/// start-to-end by `edge_subcurve`), or `None` for the B-spline fallback.
435///
436/// The second value describes the parameterization ISO 10303-42 gives the
437/// entity that was written, because a pcurve on this edge has to share THAT
438/// parameter — not the kernel's knot values (see `step/pcurve.rs`).
439fn write_analytic_curve(
440    writer: &mut StepWriter,
441    curve: &NurbsCurve,
442) -> Result<Option<(usize, EmittedCurve)>, String> {
443    if curve.degree == 1
444        && curve.control_points.len() == 2
445        && curve
446            .control_points
447            .iter()
448            .all(|control| (control.w - 1.0).abs() <= 1e-12)
449    {
450        let start = curve.control_points[0].point()?;
451        let end = curve.control_points[1].point()?;
452        let Ok(direction) = end.sub(start).normalized() else {
453            return Ok(None);
454        };
455        let point = write_point(writer, start)?;
456        let step_direction = write_direction(writer, direction)?;
457        let vector = writer.add(format!(
458            "VECTOR('',#{step_direction},{})",
459            real(end.sub(start).length())?
460        ));
461        return Ok(Some((
462            writer.add(format!("LINE('',#{point},#{vector})")),
463            // The VECTOR carries the full chord length, so the STEP parameter
464            // of this line is the fraction along the chord: C(s) = start +
465            // s·(end − start) over [0, 1].
466            EmittedCurve::Line { start, end },
467        )));
468    }
469    if let Some(arc) = recognize_circular_arc(curve) {
470        // ref_direction points at the edge's start vertex and the arc runs
471        // counterclockwise about the axis, so the importer's vertex-trimmed
472        // CCW rebuild reproduces the same directed curve with sense .T.
473        let placement = write_placement(writer, arc.center, arc.axis, arc.x_axis)?;
474        return Ok(Some((
475            writer.add(format!("CIRCLE('',#{placement},{})", real(arc.radius)?)),
476            EmittedCurve::Circle {
477                center: arc.center,
478                x_axis: arc.x_axis,
479                y_axis: arc.y_axis,
480                radius: arc.radius,
481                sweep: arc.sweep,
482                spans: arc.spans,
483            },
484        )));
485    }
486    Ok(None)
487}
488
489fn write_curve(writer: &mut StepWriter, curve: &NurbsCurve) -> Result<usize, String> {
490    let points = curve
491        .control_points
492        .iter()
493        .map(|control| write_point(writer, control.point()?))
494        .collect::<Result<Vec<_>, _>>()?;
495    write_bspline_curve(writer, curve, &points)
496}
497
498/// The B_SPLINE_CURVE_WITH_KNOTS entity (or its rational complex form) over
499/// control points that have ALREADY been written — shared by the 3D edge
500/// curves and by the 2D pcurves, whose CARTESIAN_POINTs carry two coordinates
501/// instead of three but whose degree/knots/weights are written identically.
502fn write_bspline_curve(
503    writer: &mut StepWriter,
504    curve: &NurbsCurve,
505    points: &[usize],
506) -> Result<usize, String> {
507    let (knot_values, multiplicities) = knot_runs(&curve.knots);
508    let multiplicities = format!(
509        "({})",
510        multiplicities
511            .iter()
512            .map(usize::to_string)
513            .collect::<Vec<_>>()
514            .join(",")
515    );
516    let knots = format!(
517        "({})",
518        knot_values
519            .iter()
520            .map(|value| real(*value))
521            .collect::<Result<Vec<_>, _>>()?
522            .join(",")
523    );
524    let rational = curve
525        .control_points
526        .iter()
527        .any(|control| (control.w - 1.0).abs() > 1e-12);
528    if !rational {
529        return Ok(writer.add(format!(
530            "B_SPLINE_CURVE_WITH_KNOTS('',{},{},.UNSPECIFIED.,.F.,.F.,{multiplicities},{knots},.UNSPECIFIED.)",
531            curve.degree,
532            id_list(points),
533        )));
534    }
535    let weights = format!(
536        "({})",
537        curve
538            .control_points
539            .iter()
540            .map(|control| real(control.w))
541            .collect::<Result<Vec<_>, _>>()?
542            .join(",")
543    );
544    Ok(writer.add(format!(
545        "(BOUNDED_CURVE()B_SPLINE_CURVE({},{},.UNSPECIFIED.,.F.,.F.)\
546         B_SPLINE_CURVE_WITH_KNOTS({multiplicities},{knots},.UNSPECIFIED.)\
547         CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE({weights})\
548         REPRESENTATION_ITEM(''))",
549        curve.degree,
550        id_list(points),
551    )))
552}
553
554fn write_point_2d(writer: &mut StepWriter, point: [f64; 2]) -> Result<usize, String> {
555    Ok(writer.add(format!(
556        "CARTESIAN_POINT('',({},{}))",
557        real(point[0])?,
558        real(point[1])?
559    )))
560}
561
562fn write_direction_2d(writer: &mut StepWriter, direction: [f64; 2]) -> Result<usize, String> {
563    Ok(writer.add(format!(
564        "DIRECTION('',({},{}))",
565        real(direction[0])?,
566        real(direction[1])?
567    )))
568}
569
570/// Write the 2D geometry of one pcurve.
571///
572/// The `VECTOR` of a 2D LINE carries its TRUE magnitude rather than being
573/// normalized to 1: ISO 10303-42 parameterizes a line as pnt + u·(magnitude ×
574/// orientation), so the magnitude is what makes the 2D parameter equal the 3D
575/// entity's parameter exactly.  (OpenCASCADE normalizes every VECTOR it writes
576/// and then loses that agreement wherever the two speeds differ — its own cone
577/// ruling pcurves are off by cos(semi-angle) — so this is strictly the more
578/// faithful of the two conventions, and identical wherever the speeds agree.)
579fn write_pcurve_geometry(writer: &mut StepWriter, curve: &Pcurve2d) -> Result<usize, String> {
580    match curve {
581        Pcurve2d::Line { point, vector } => {
582            let magnitude = vector[0].hypot(vector[1]);
583            if magnitude <= 0.0 {
584                return Err("export_step: degenerate 2D line pcurve".into());
585            }
586            let point_id = write_point_2d(writer, *point)?;
587            let direction =
588                write_direction_2d(writer, [vector[0] / magnitude, vector[1] / magnitude])?;
589            let vector_id = writer.add(format!(
590                "VECTOR('',#{direction},{})",
591                real(magnitude)?
592            ));
593            Ok(writer.add(format!("LINE('',#{point_id},#{vector_id})")))
594        }
595        Pcurve2d::Circle {
596            center,
597            ref_direction,
598            radius,
599        } => {
600            let center_id = write_point_2d(writer, *center)?;
601            let direction = write_direction_2d(writer, *ref_direction)?;
602            let placement = writer.add(format!(
603                "AXIS2_PLACEMENT_2D('',#{center_id},#{direction})"
604            ));
605            Ok(writer.add(format!("CIRCLE('',#{placement},{})", real(*radius)?)))
606        }
607        Pcurve2d::Spline(spline) => {
608            let points = spline
609                .control_points
610                .iter()
611                .map(|control| {
612                    let point = control.point()?;
613                    write_point_2d(writer, [point.x, point.y])
614                })
615                .collect::<Result<Vec<_>, String>>()?;
616            write_bspline_curve(writer, spline, &points)
617        }
618    }
619}
620
621/// `PCURVE('', surface, DEFINITIONAL_REPRESENTATION('', (2d curve), ctx))` —
622/// the association of one 2D curve with the surface it parameterizes.
623fn write_pcurve_entity(
624    writer: &mut StepWriter,
625    surface_id: usize,
626    context_2d: usize,
627    curve: &Pcurve2d,
628) -> Result<usize, String> {
629    let geometry = write_pcurve_geometry(writer, curve)?;
630    let representation = writer.add(format!(
631        "DEFINITIONAL_REPRESENTATION('',(#{geometry}),#{context_2d})"
632    ));
633    Ok(writer.add(format!("PCURVE('',#{surface_id},#{representation})")))
634}
635
636fn write_surface(writer: &mut StepWriter, surface: &NurbsSurface) -> Result<usize, String> {
637    let rows = surface
638        .control_points
639        .iter()
640        .map(|row| {
641            row.iter()
642                .map(|control| write_point(writer, control.point()?))
643                .collect::<Result<Vec<_>, _>>()
644                .map(|ids| id_list(&ids))
645        })
646        .collect::<Result<Vec<_>, _>>()?;
647    let grid = format!("({})", rows.join(","));
648    let (u_values, u_multiplicities) = knot_runs(&surface.knots_u);
649    let (v_values, v_multiplicities) = knot_runs(&surface.knots_v);
650    let multiplicities = |values: &[usize]| {
651        format!(
652            "({})",
653            values
654                .iter()
655                .map(usize::to_string)
656                .collect::<Vec<_>>()
657                .join(",")
658        )
659    };
660    let knots = |values: &[f64]| -> Result<String, String> {
661        Ok(format!(
662            "({})",
663            values
664                .iter()
665                .map(|value| real(*value))
666                .collect::<Result<Vec<_>, _>>()?
667                .join(",")
668        ))
669    };
670    let u_mults = multiplicities(&u_multiplicities);
671    let v_mults = multiplicities(&v_multiplicities);
672    let u_knots = knots(&u_values)?;
673    let v_knots = knots(&v_values)?;
674    let rational = surface
675        .control_points
676        .iter()
677        .flatten()
678        .any(|control| (control.w - 1.0).abs() > 1e-12);
679    if !rational {
680        return Ok(writer.add(format!(
681            "B_SPLINE_SURFACE_WITH_KNOTS('',{},{},{grid},.UNSPECIFIED.,.F.,.F.,.F.,\
682             {u_mults},{v_mults},{u_knots},{v_knots},.UNSPECIFIED.)",
683            surface.degree_u, surface.degree_v,
684        )));
685    }
686    let weights = format!(
687        "({})",
688        surface
689            .control_points
690            .iter()
691            .map(|row| {
692                row.iter()
693                    .map(|control| real(control.w))
694                    .collect::<Result<Vec<_>, _>>()
695                    .map(|values| format!("({})", values.join(",")))
696            })
697            .collect::<Result<Vec<_>, _>>()?
698            .join(",")
699    );
700    Ok(writer.add(format!(
701        "(BOUNDED_SURFACE()B_SPLINE_SURFACE({},{},{grid},.UNSPECIFIED.,.F.,.F.,.F.)\
702         B_SPLINE_SURFACE_WITH_KNOTS({u_mults},{v_mults},{u_knots},{v_knots},.UNSPECIFIED.)\
703         GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE({weights})\
704         REPRESENTATION_ITEM('')SURFACE())",
705        surface.degree_u, surface.degree_v,
706    )))
707}
708
709fn write_length_unit(writer: &mut StepWriter, unit: &str) -> Result<usize, String> {
710    let normalized = unit.to_lowercase();
711    if normalized == "meter" || normalized == "metre" {
712        return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.))"));
713    }
714    if normalized == "centimeter" || normalized == "centimetre" {
715        return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))"));
716    }
717    if matches!(normalized.as_str(), "micron" | "micrometer" | "micrometre") {
718        return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MICRO.,.METRE.))"));
719    }
720    if normalized == "inch" || normalized == "foot" {
721        let metre = writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.))");
722        let (factor, name) = if normalized == "inch" {
723            (0.0254, "INCH")
724        } else {
725            (0.3048, "FOOT")
726        };
727        let measure = writer.add(format!(
728            "LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{metre})",
729            real(factor)?
730        ));
731        return Ok(writer.add(format!(
732            "(CONVERSION_BASED_UNIT('{name}',#{measure})LENGTH_UNIT()NAMED_UNIT(*))"
733        )));
734    }
735    Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))"))
736}
737
738fn vertex_for(solid: &BrepSolid, id: u64) -> Result<&VertexRecord, String> {
739    solid
740        .vertices
741        .iter()
742        .find(|vertex| vertex.id == id)
743        .ok_or_else(|| format!("export_step: missing vertex {id}"))
744}
745
746fn edge_for(solid: &BrepSolid, id: u64) -> Result<&EdgeRecord, String> {
747    solid
748        .edges
749        .iter()
750        .find(|edge| edge.id == id)
751        .ok_or_else(|| format!("export_step: missing edge {id}"))
752}
753
754fn surface_key(face: &FaceRecord) -> usize {
755    face as *const FaceRecord as usize
756}
757
758/// One use of an edge by a face's loop.  Curve-on-surface geometry is written
759/// per USE — a pcurve names the surface it lives on — so the writer needs the
760/// full adjacency of an edge before it can emit that edge.
761struct CoedgeUse<'a> {
762    surface_key: usize,
763    face: &'a FaceRecord,
764    coedge: &'a CoedgeRecord,
765}
766
767/// The AP242 document plus what the writer measured while producing it.
768///
769/// The pcurve counters are the honest half of the verify-or-omit contract in
770/// `step/pcurve.rs`: a pcurve is written only when the emitted 2D geometry was
771/// proved to reproduce the emitted 3D curve, and every one that could not be
772/// proved is COUNTED here rather than guessed into the file.  A reader
773/// reprojects an omitted pcurve exactly as it must reproject every pcurve in
774/// the files this exporter wrote before curve-on-surface geometry existed.
775#[derive(Clone, Debug, Default)]
776pub struct StepExportReport {
777    /// The Part 21 text.
778    pub text: String,
779    /// `PCURVE` entities written — at most one per coedge use of a
780    /// non-degenerate edge.
781    pub pcurves_written: usize,
782    /// Coedge uses left without a pcurve because no candidate 2D geometry
783    /// verified inside the export band.
784    pub pcurves_omitted: usize,
785    /// Edges written as `SURFACE_CURVE` (their two uses sit on two surfaces).
786    pub surface_curves: usize,
787    /// Edges written as `SEAM_CURVE` (both uses on ONE surface — a periodic
788    /// seam, the case an importer otherwise has to re-detect geometrically).
789    pub seam_curves: usize,
790    /// Edges that kept a bare 3D curve for `edge_geometry`, because no pcurve
791    /// survived (or a seam lost one of the pair it is required to carry).
792    pub bare_curves: usize,
793    /// Collapsed face boundaries written as `VERTEX_LOOP` — cone apexes and
794    /// sphere poles, which this writer used to drop entirely.
795    pub vertex_loops: usize,
796    /// Largest verified ‖S(c₂d(s)) − c₃d(s)‖ among the pcurves actually
797    /// written, in model units.
798    pub max_pcurve_deviation: f64,
799    /// Largest deviation among the BEST candidate for each OMITTED pcurve —
800    /// how far the closest miss was, so an omission can be diagnosed as "a
801    /// candidate applied and missed the band by this much" rather than only
802    /// counted. Zero when nothing was omitted.
803    ///
804    /// It is infinite when ANY omission had no candidate proposed at all, which
805    /// then hides the finite misses behind it — the two omission classes share
806    /// one counter. Splitting them is a follow-up; the max is the useful half,
807    /// because it is the finite value that says whether widening the band would
808    /// have helped.
809    pub worst_omitted_deviation: f64,
810    /// DISTINCT PMI reference names (`{body}`, `{body}:face`, `{body}@x,y,z`)
811    /// that named no entity in the written file, so every annotation on them
812    /// was skipped. Zero for a file whose PMI all resolved. The structured
813    /// (assembly) lane is what can move a reference: a component's geometry is
814    /// written into the PART's product, and a stale reference to a body that no
815    /// longer exists is counted here rather than dropped in silence.
816    pub pmi_unresolved_references: usize,
817    /// PRODUCTs written. `1` for the flat lane; the structured lane writes one
818    /// per distinct part plus one for the root document.
819    pub products: usize,
820    /// `NEXT_ASSEMBLY_USAGE_OCCURRENCE` edges written — the placed component
821    /// instances. Zero for the flat lane.
822    pub occurrences: usize,
823}
824
825/// Serialize exact NURBS BREP topology as an AP242 STEP Part 21 document.
826pub fn export_step(
827    solids: &[BrepSolid],
828    name: &str,
829    unit: &str,
830    timestamp: &str,
831) -> Result<String, String> {
832    export_step_report(solids, name, unit, timestamp).map(|report| report.text)
833}
834
835/// [`export_step`] plus the pcurve-coverage measurements behind the file.
836/// Every body is written under the part name; no PMI.
837pub fn export_step_report(
838    solids: &[BrepSolid],
839    name: &str,
840    unit: &str,
841    timestamp: &str,
842) -> Result<StepExportReport, String> {
843    let named: Vec<(String, &BrepSolid)> = solids
844        .iter()
845        .map(|solid| (name.to_string(), solid))
846        .collect();
847    export_step_report_named(&named, name, unit, timestamp, None)
848}
849
850/// The full writer: each body carries its SCENE NAME (`MANIFOLD_SOLID_BREP`
851/// name — and the key the PMI references resolve through), and an optional
852/// PMI block is written as AP242 semantic representation + polyline
853/// presentation + saved views ([`pmi`]).
854/// Where an exported geometry entity lives: the product whose shape it helps
855/// define, and the representation that carries it. PMI attaches a shape aspect
856/// to the OWNING product, which in a structured (assembly) export is the part's
857/// own product, not the root document's.
858#[derive(Clone, Copy)]
859pub(crate) struct StepItemOwner {
860    pub product_shape: usize,
861    pub representation: usize,
862}
863
864/// One product's geometry, written but not yet bound to a product definition.
865/// The name lists are flat (not maps) because the same part can be reachable
866/// under several occurrence paths, and each path registers its own alias.
867#[derive(Default)]
868pub(crate) struct ProductGeometry {
869    /// `MANIFOLD_SOLID_BREP` ids, in body order.
870    pub solids: Vec<usize>,
871    /// Face name -> its `ADVANCED_FACE`.
872    pub faces: Vec<(String, usize)>,
873    /// Edge name -> its `EDGE_CURVE`.
874    pub edges: Vec<(String, usize)>,
875    /// Body name -> that body's `VERTEX_POINT`s (LOCAL point, entity id).
876    pub vertices: Vec<(String, Vec<(Vec3, usize)>)>,
877}
878
879/// The PMI reference maps, folded from every product's [`ProductGeometry`] once
880/// its owner is known. A structured export registers each product's entities
881/// under EVERY occurrence path that reaches it (`ACOMP3:`, `ACOMP5:ACOMP1:`),
882/// which is exactly how the document names a component's geometry.
883#[derive(Default)]
884pub(crate) struct StepNameMaps {
885    pub faces: HashMap<String, (usize, StepItemOwner)>,
886    pub edges: HashMap<String, (usize, StepItemOwner)>,
887    /// Body name -> its owner and its `VERTEX_POINT`s in ROOT (document) space,
888    /// because that is the frame a `{body}@x,y,z` PMI reference is written in.
889    pub vertices: HashMap<String, (StepItemOwner, Vec<(Vec3, usize)>)>,
890}
891
892impl StepNameMaps {
893    /// Register one product's entities under an occurrence path: `prefix` is the
894    /// chained component namespace (`""` at the root), `world` maps the
895    /// product's local frame into root space.
896    pub(crate) fn register(
897        &mut self,
898        geometry: &ProductGeometry,
899        owner: StepItemOwner,
900        prefix: &str,
901        world: &Mat4,
902    ) {
903        for (name, id) in &geometry.faces {
904            self.faces
905                .entry(format!("{prefix}{name}"))
906                .or_insert((*id, owner));
907        }
908        for (name, id) in &geometry.edges {
909            self.edges
910                .entry(format!("{prefix}{name}"))
911                .or_insert((*id, owner));
912        }
913        for (name, points) in &geometry.vertices {
914            let placed = points
915                .iter()
916                .map(|(point, id)| (transform_point(world, *point), *id))
917                .collect();
918            self.vertices
919                .entry(format!("{prefix}{name}"))
920                .or_insert((owner, placed));
921        }
922    }
923}
924
925/// The file-wide entities every product's geometry is expressed against. One
926/// set per FILE, shared by every product a structured export writes.
927pub(crate) struct StepFileContexts {
928    pub product_context: usize,
929    pub definition_context: usize,
930    pub geometry_context: usize,
931    pub parametric_context: usize,
932    pub length_unit: usize,
933    pub angle_unit: usize,
934    /// The IDENTITY `AXIS2_PLACEMENT_3D` — the first item of every shape
935    /// representation, and `item_1` of every assembly placement's
936    /// `ITEM_DEFINED_TRANSFORMATION` (the child frame's own origin).
937    pub axis: usize,
938}
939
940/// Write the header entities every AP242 body in this file shares.
941pub(crate) fn write_file_contexts(
942    writer: &mut StepWriter,
943    unit: &str,
944) -> Result<StepFileContexts, String> {
945    let application = writer.add("APPLICATION_CONTEXT('managed model based 3d engineering')");
946    writer.add(format!(
947        "APPLICATION_PROTOCOL_DEFINITION('international standard','ap242_managed_model_based_3d_engineering',2014,#{application})"
948    ));
949    let product_context = writer.add(format!("PRODUCT_CONTEXT('',#{application},'mechanical')"));
950    let definition_context = writer.add(format!(
951        "PRODUCT_DEFINITION_CONTEXT('part definition',#{application},'design')"
952    ));
953    let length_unit = write_length_unit(writer, unit)?;
954    let angle_unit = writer.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))");
955    let solid_angle_unit = writer.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())");
956    let uncertainty = writer.add(format!(
957        "UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{length_unit},'distance_accuracy_value','')"
958    ));
959    let geometry_context = writer.add(format!(
960        "(GEOMETRIC_REPRESENTATION_CONTEXT(3)\
961         GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{uncertainty}))\
962         GLOBAL_UNIT_ASSIGNED_CONTEXT((#{length_unit},#{angle_unit},#{solid_angle_unit}))\
963         REPRESENTATION_CONTEXT('',''))"
964    ));
965    // ONE parametric context for every DEFINITIONAL_REPRESENTATION in the file.
966    // It carries no unit assignment, so the importer's file-scale search
967    // (`derive_length_scale_mm`, which keys on GLOBAL_UNIT_ASSIGNED_CONTEXT /
968    // LENGTH_UNIT) cannot mistake it for the geometric context.
969    let parametric_context = writer.add(
970        "(GEOMETRIC_REPRESENTATION_CONTEXT(2)\
971         PARAMETRIC_REPRESENTATION_CONTEXT()\
972         REPRESENTATION_CONTEXT('2D SPACE',''))",
973    );
974    let origin = write_point(writer, Vec3::default())?;
975    let direction_z = writer.add("DIRECTION('',(0.,0.,1.))");
976    let direction_x = writer.add("DIRECTION('',(1.,0.,0.))");
977    let axis = writer.add(format!(
978        "AXIS2_PLACEMENT_3D('',#{origin},#{direction_z},#{direction_x})"
979    ));
980    Ok(StepFileContexts {
981        product_context,
982        definition_context,
983        geometry_context,
984        parametric_context,
985        length_unit,
986        angle_unit,
987        axis,
988    })
989}
990
991/// The product-definition chain of ONE product: what a `NEXT_ASSEMBLY_USAGE_
992/// OCCURRENCE` references and what a `SHAPE_DEFINITION_REPRESENTATION` binds to
993/// its geometry.
994pub(crate) struct ProductIds {
995    pub definition: usize,
996    pub product_shape: usize,
997}
998
999/// PRODUCT -> PRODUCT_DEFINITION -> PRODUCT_DEFINITION_SHAPE for one product.
1000/// `id` is the vendor part number (`PRODUCT.id`); the name is used when empty.
1001/// `category` is the `PRODUCT_RELATED_PRODUCT_CATEGORY` name — `'part'` for a
1002/// leaf, `'assembly'` for a product that places others, which is the
1003/// distinction a receiving system reads to build its own structure tree.
1004pub(crate) fn write_product(
1005    writer: &mut StepWriter,
1006    contexts: &StepFileContexts,
1007    name: &str,
1008    id: &str,
1009    category: &str,
1010) -> ProductIds {
1011    let safe_name = step_string(name);
1012    let safe_id = step_string(if id.is_empty() { name } else { id });
1013    let product_context = contexts.product_context;
1014    let definition_context = contexts.definition_context;
1015    let product = writer.add(format!(
1016        "PRODUCT('{safe_id}','{safe_name}','',(#{product_context}))"
1017    ));
1018    writer.add(format!(
1019        "PRODUCT_RELATED_PRODUCT_CATEGORY('{}','',(#{product}))",
1020        step_string(category)
1021    ));
1022    let formation = writer.add(format!("PRODUCT_DEFINITION_FORMATION('','',#{product})"));
1023    let definition = writer.add(format!(
1024        "PRODUCT_DEFINITION('design','',#{formation},#{definition_context})"
1025    ));
1026    let product_shape = writer.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{definition})"));
1027    ProductIds {
1028        definition,
1029        product_shape,
1030    }
1031}
1032
1033/// Validate every body the way the export gate requires, then write each one's
1034/// exact topology as a `MANIFOLD_SOLID_BREP` named by its scene name.
1035///
1036/// This is the whole geometry writer, factored out of the single-product lane so
1037/// the structured (assembly) lane can call it once PER PRODUCT into the same
1038/// file. It appends to `writer` and reads only file-wide entities, so products
1039/// share surfaces with nothing and cannot collide.
1040pub(crate) fn write_product_geometry(
1041    writer: &mut StepWriter,
1042    contexts: &StepFileContexts,
1043    bodies: &[(String, &BrepSolid)],
1044    report: &mut StepExportReport,
1045) -> Result<ProductGeometry, String> {
1046    for (_, solid) in bodies {
1047        let policy = KernelTolerances::for_solid(solid, 1e-7);
1048        let issues = solid.validate_with_tolerances(&KernelTolerances {
1049            pcurve_consistency: policy.export_knit,
1050            ..policy
1051        });
1052        if !issues.is_empty() {
1053            return Err(format!("export_step: invalid solid: {issues:?}"));
1054        }
1055    }
1056    let parametric_context = contexts.parametric_context;
1057    let mut written = ProductGeometry::default();
1058    for (solid_name, solid) in bodies {
1059        let solid = *solid;
1060        let solid_name = solid_name.as_str();
1061        let band = KernelTolerances::for_solid(solid, 1e-7).export_knit;
1062        let mut vertex_ids = HashMap::<u64, usize>::default();
1063        let mut edge_ids = HashMap::<u64, usize>::default();
1064        let mut surfaces = HashMap::<usize, (usize, bool, EmittedSurface)>::default();
1065        for shell in &solid.shells {
1066            // Pass 1 — SURFACES. A pcurve references the surface entity it
1067            // parameterizes, so every surface id has to exist before the first
1068            // edge is written. (Before curve-on-surface geometry the writer
1069            // could emit surfaces after the loops; it no longer can.)
1070            for face in &shell.faces {
1071                let key = surface_key(face);
1072                if surfaces.contains_key(&key) {
1073                    continue;
1074                }
1075                let entry = match write_analytic_surface(writer, &face.surface)? {
1076                    Some(triple) => triple,
1077                    None => (
1078                        write_surface(writer, &face.surface)?,
1079                        false,
1080                        EmittedSurface::Spline,
1081                    ),
1082                };
1083                surfaces.insert(key, entry);
1084            }
1085
1086            // Pass 2 — ADJACENCY. Which faces use each edge, in first-encounter
1087            // order so the emitted entity numbering stays deterministic.
1088            let mut edge_uses = HashMap::<u64, Vec<CoedgeUse>>::default();
1089            let mut edge_order: Vec<u64> = Vec::new();
1090            for face in &shell.faces {
1091                for loop_record in &face.loops {
1092                    for coedge in &loop_record.coedges {
1093                        let edge = edge_for(solid, coedge.edge_id)?;
1094                        if edge.degenerate {
1095                            continue;
1096                        }
1097                        let uses = edge_uses.entry(edge.id).or_default();
1098                        if uses.is_empty() {
1099                            edge_order.push(edge.id);
1100                        }
1101                        uses.push(CoedgeUse {
1102                            surface_key: surface_key(face),
1103                            face,
1104                            coedge,
1105                        });
1106                    }
1107                }
1108            }
1109
1110            // Pass 3 — EDGES with their curve-on-surface geometry.
1111            for edge_id in &edge_order {
1112                if edge_ids.contains_key(edge_id) {
1113                    continue;
1114                }
1115                let edge = edge_for(solid, *edge_id)?;
1116                let subcurve = edge_subcurve(edge)?;
1117                let (curve, emitted_curve) = match write_analytic_curve(writer, &subcurve)? {
1118                    Some(pair) => pair,
1119                    None => (
1120                        write_curve(writer, &subcurve)?,
1121                        EmittedCurve::Spline { curve: subcurve },
1122                    ),
1123                };
1124                let uses = &edge_uses[edge_id];
1125                // Two uses on ONE surface is a periodic seam: the two halves of
1126                // the edge sit on opposite domain boundaries and the reader is
1127                // told so explicitly instead of having to rediscover it.
1128                let seam = uses.len() == 2 && uses[0].surface_key == uses[1].surface_key;
1129                let mut pcurves: Vec<(usize, Pcurve2d)> = Vec::new();
1130                let mut omitted = 0usize;
1131                if uses.len() == 2 {
1132                    // Slot order for a seam: the FORWARD-oriented coedge first,
1133                    // the reversed one second — the pairing OpenCASCADE writes
1134                    // and reads (BRep_CurveOnClosedSurface's PCurve1/PCurve2).
1135                    let mut ordered: Vec<&CoedgeUse> = uses.iter().collect();
1136                    if seam && !ordered[0].coedge.forward {
1137                        ordered.swap(0, 1);
1138                    }
1139                    for coedge_use in ordered {
1140                        // The stored pcurve runs in LOOP direction; the edge's
1141                        // geometry runs in EDGE direction. Fraction-synchronised
1142                        // reversal is exact, so a reversed coedge's pcurve is
1143                        // simply flipped before it is expressed.
1144                        let oriented = if coedge_use.coedge.forward {
1145                            coedge_use.coedge.pcurve.clone()
1146                        } else {
1147                            coedge_use.coedge.pcurve.reversed()?
1148                        };
1149                        let (surface_id, _, emitted_surface) = &surfaces[&coedge_use.surface_key];
1150                        let outcome = build_pcurve(
1151                            &coedge_use.face.surface,
1152                            emitted_surface,
1153                            &emitted_curve,
1154                            &oriented,
1155                            band,
1156                        )?;
1157                        match outcome.curve {
1158                            Some(curve_2d) => {
1159                                report.max_pcurve_deviation =
1160                                    report.max_pcurve_deviation.max(outcome.deviation);
1161                                pcurves.push((*surface_id, curve_2d));
1162                            }
1163                            None => {
1164                                omitted += 1;
1165                                if outcome.deviation.is_finite() {
1166                                    report.worst_omitted_deviation =
1167                                        report.worst_omitted_deviation.max(outcome.deviation);
1168                                } else {
1169                                    report.worst_omitted_deviation = f64::INFINITY;
1170                                }
1171                            }
1172                        }
1173                    }
1174                }
1175                // A SEAM_CURVE is required to carry BOTH pcurves on the one
1176                // surface, so a seam that lost one falls all the way back to a
1177                // bare 3D curve rather than to a half-described seam.
1178                if seam && pcurves.len() != 2 {
1179                    omitted += pcurves.len();
1180                    pcurves.clear();
1181                }
1182                report.pcurves_omitted += omitted;
1183                report.pcurves_written += pcurves.len();
1184                let geometry = if pcurves.is_empty() {
1185                    report.bare_curves += 1;
1186                    curve
1187                } else {
1188                    let ids = pcurves
1189                        .iter()
1190                        .map(|(surface_id, curve_2d)| {
1191                            write_pcurve_entity(
1192                                writer,
1193                                *surface_id,
1194                                parametric_context,
1195                                curve_2d,
1196                            )
1197                        })
1198                        .collect::<Result<Vec<_>, String>>()?;
1199                    let keyword = if seam {
1200                        report.seam_curves += 1;
1201                        "SEAM_CURVE"
1202                    } else {
1203                        report.surface_curves += 1;
1204                        "SURFACE_CURVE"
1205                    };
1206                    // master_representation is .CURVE_3D.: our 3D curves are
1207                    // the exact authority the whole kernel and the export gate
1208                    // treat them as, and the pcurves above are verified AGAINST
1209                    // them. Promoting an approximation to master would be the
1210                    // one claim this writer must not make.
1211                    writer.add(format!(
1212                        "{keyword}('',#{curve},{},.CURVE_3D.)",
1213                        id_list(&ids)
1214                    ))
1215                };
1216                let start = vertex_step_id(writer, &mut vertex_ids, solid, edge.start_vertex_id)?;
1217                let end = vertex_step_id(writer, &mut vertex_ids, solid, edge.end_vertex_id)?;
1218                let step_id =
1219                    writer.add(format!("EDGE_CURVE('',#{start},#{end},#{geometry},.T.)"));
1220                edge_ids.insert(edge.id, step_id);
1221                if let Some(edge_name) = edge.name.as_deref() {
1222                    written.edges.push((edge_name.to_string(), step_id));
1223                }
1224            }
1225
1226            // Pass 4 — LOOPS and FACES over the ids the passes above fixed.
1227            let mut face_ids = Vec::new();
1228            for face in &shell.faces {
1229                let mut bound_ids = Vec::new();
1230                for (loop_index, loop_record) in face.loops.iter().enumerate() {
1231                    let mut oriented_edges = Vec::new();
1232                    for coedge in &loop_record.coedges {
1233                        let edge = edge_for(solid, coedge.edge_id)?;
1234                        if edge.degenerate {
1235                            continue;
1236                        }
1237                        let edge_id = *edge_ids
1238                            .get(&edge.id)
1239                            .ok_or_else(|| format!("export_step: unwritten edge {}", edge.id))?;
1240                        let orientation = if coedge.forward { ".T." } else { ".F." };
1241                        oriented_edges.push(
1242                            writer.add(format!("ORIENTED_EDGE('',*,*,#{edge_id},{orientation})")),
1243                        );
1244                    }
1245                    let kind = if loop_index == 0 {
1246                        "FACE_OUTER_BOUND"
1247                    } else {
1248                        "FACE_BOUND"
1249                    };
1250                    if oriented_edges.is_empty() {
1251                        // A bound left empty by the degenerate-edge skip is a
1252                        // COLLAPSED boundary — a cone apex, a sphere pole —
1253                        // and AP242 spells that `VERTEX_LOOP`, not nothing.
1254                        // Dropping it (what this writer did before) deletes the
1255                        // apex from the face's parameter domain, so an imported
1256                        // pointed cone lost its apex bound on re-export and
1257                        // every reader had to re-synthesise it from the
1258                        // surface's own degeneracy. A MIXED loop keeps the
1259                        // skip: that is OCCT's own write convention, both our
1260                        // importer and OCC's ShapeFix rebuild those, and the
1261                        // advanced-face conformance class permits vertex loops
1262                        // but not zero-length edge curves.
1263                        let Some(coedge) = loop_record.coedges.first() else {
1264                            continue;
1265                        };
1266                        let collapsed = edge_for(solid, coedge.edge_id)?;
1267                        let vertex = vertex_step_id(
1268                            writer,
1269                            &mut vertex_ids,
1270                            solid,
1271                            collapsed.start_vertex_id,
1272                        )?;
1273                        let vertex_loop = writer.add(format!("VERTEX_LOOP('',#{vertex})"));
1274                        report.vertex_loops += 1;
1275                        bound_ids.push(writer.add(format!("{kind}('',#{vertex_loop},.T.)")));
1276                        continue;
1277                    }
1278                    let edge_loop =
1279                        writer.add(format!("EDGE_LOOP('',{})", id_list(&oriented_edges)));
1280                    bound_ids.push(writer.add(format!("{kind}('',#{edge_loop},.T.)")));
1281                }
1282                let (surface, flipped, _) = surfaces[&surface_key(face)];
1283                // `flipped` analytic entities are written with the reverse of
1284                // the stored NURBS orientation, so invert the flag to keep the
1285                // face normal identical through the round trip.
1286                let sense = if face.same_sense != flipped {
1287                    ".T."
1288                } else {
1289                    ".F."
1290                };
1291                let face_step_id = writer.add(format!(
1292                    "ADVANCED_FACE('',{},#{surface},{sense})",
1293                    id_list(&bound_ids)
1294                ));
1295                if let Some(face_name) = face.name.as_deref() {
1296                    written.faces.push((face_name.to_string(), face_step_id));
1297                }
1298                face_ids.push(face_step_id);
1299            }
1300            let closed_shell = writer.add(format!("CLOSED_SHELL('',{})", id_list(&face_ids)));
1301            written.solids.push(writer.add(format!(
1302                "MANIFOLD_SOLID_BREP('{}',#{closed_shell})",
1303                step_string(solid_name)
1304            )));
1305        }
1306        // The vertices this solid wrote, for `{solid}@x,y,z` PMI references.
1307        let mut points: Vec<(Vec3, usize)> = Vec::with_capacity(vertex_ids.len());
1308        for (vertex_id, step_id) in &vertex_ids {
1309            points.push((vertex_for(solid, *vertex_id)?.point, *step_id));
1310        }
1311        written.vertices.push((solid_name.to_string(), points));
1312    }
1313    Ok(written)
1314}
1315
1316/// Wrap the written entities in the Part 21 envelope and run the emitted-text
1317/// audits — the last step of every export lane.
1318pub(crate) fn finish_step_file(
1319    writer: StepWriter,
1320    name: &str,
1321    timestamp: &str,
1322    report: &mut StepExportReport,
1323) -> Result<(), String> {
1324    let safe_name = step_string(name);
1325    let safe_timestamp = step_string(timestamp);
1326    let output = [
1327        "ISO-10303-21;".to_string(),
1328        "HEADER;".to_string(),
1329        "FILE_DESCRIPTION((''),'2;1');".to_string(),
1330        format!(
1331            "FILE_NAME('{safe_name}.step','{safe_timestamp}',(''),(''),'brep-kernel-rs','brep-kernel-rs','');"
1332        ),
1333        "FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 1 1 4 }'));".to_string(),
1334        "ENDSEC;".to_string(),
1335        "DATA;".to_string(),
1336        writer.data(),
1337        "ENDSEC;".to_string(),
1338        "END-ISO-10303-21;".to_string(),
1339        String::new(),
1340    ]
1341    .join("\n");
1342    let manifold_issues = audit_step_manifold(&output);
1343    if !manifold_issues.is_empty() {
1344        return Err(format!(
1345            "export_step: emitted AP242 manifold audit failed: {}",
1346            manifold_issues.join("; ")
1347        ));
1348    }
1349    let pcurve_issues = audit_step_pcurves(&output);
1350    if !pcurve_issues.is_empty() {
1351        return Err(format!(
1352            "export_step: emitted AP242 pcurve audit failed: {}",
1353            pcurve_issues.join("; ")
1354        ));
1355    }
1356    report.text = output;
1357    Ok(())
1358}
1359
1360/// The full single-product writer: each body carries its SCENE NAME
1361/// (`MANIFOLD_SOLID_BREP` name — and the key the PMI references resolve
1362/// through), and an optional PMI block is written as AP242 semantic
1363/// representation + polyline presentation + saved views ([`pmi`]).
1364///
1365/// This is the FLAT lane: one `PRODUCT`, every body in it, no assembly
1366/// structure. A document with components goes through
1367/// [`assembly::export_step_assembly_report`] instead.
1368pub fn export_step_report_named(
1369    solids: &[(String, &BrepSolid)],
1370    name: &str,
1371    unit: &str,
1372    timestamp: &str,
1373    pmi: Option<&StepPmi<'_>>,
1374) -> Result<StepExportReport, String> {
1375    if solids.is_empty() {
1376        return Err("export_step: at least one solid is required".into());
1377    }
1378    let mut report = StepExportReport {
1379        products: 1,
1380        ..StepExportReport::default()
1381    };
1382    let mut writer = StepWriter::default();
1383    let contexts = write_file_contexts(&mut writer, unit)?;
1384    let geometry = write_product_geometry(&mut writer, &contexts, solids, &mut report)?;
1385    let mut items = vec![contexts.axis];
1386    items.extend(&geometry.solids);
1387    let geometry_context = contexts.geometry_context;
1388    let representation = writer.add(format!(
1389        "ADVANCED_BREP_SHAPE_REPRESENTATION('',{},#{geometry_context})",
1390        id_list(&items)
1391    ));
1392    let product = write_product(&mut writer, &contexts, name, "", "part");
1393    let product_shape = product.product_shape;
1394    writer.add(format!(
1395        "SHAPE_DEFINITION_REPRESENTATION(#{product_shape},#{representation})"
1396    ));
1397    if let Some(pmi) = pmi {
1398        let mut names = StepNameMaps::default();
1399        names.register(
1400            &geometry,
1401            StepItemOwner {
1402                product_shape,
1403                representation,
1404            },
1405            "",
1406            &MAT4_IDENTITY,
1407        );
1408        let context = pmi::StepContext {
1409            product_shape,
1410            representation,
1411            geometry_context,
1412            length_unit: contexts.length_unit,
1413            angle_unit: contexts.angle_unit,
1414            faces: &names.faces,
1415            edges: &names.edges,
1416            vertices: &names.vertices,
1417        };
1418        report.pmi_unresolved_references = pmi::write_pmi(&mut writer, &context, pmi)?;
1419    }
1420    finish_step_file(writer, name, timestamp, &mut report)?;
1421    Ok(report)
1422}
1423
1424/// VERTEX_POINT for a kernel vertex, written once per solid.
1425fn vertex_step_id(
1426    writer: &mut StepWriter,
1427    vertex_ids: &mut HashMap<u64, usize>,
1428    solid: &BrepSolid,
1429    id: u64,
1430) -> Result<usize, String> {
1431    if let Some(step_id) = vertex_ids.get(&id) {
1432        return Ok(*step_id);
1433    }
1434    let point = write_point(writer, vertex_for(solid, id)?.point)?;
1435    let step_id = writer.add(format!("VERTEX_POINT('',#{point})"));
1436    vertex_ids.insert(id, step_id);
1437    Ok(step_id)
1438}
1439
1440/// Entity id -> body, for the serialized-text audits.  One entity per line is
1441/// this writer's own invariant, so the "parse" is a split.
1442fn step_entity_bodies(step: &str) -> HashMap<u64, &str> {
1443    step.lines()
1444        .filter_map(|line| {
1445            let rest = line.strip_prefix('#')?;
1446            let (digits, body) = rest.split_once('=')?;
1447            Some((
1448                digits.parse::<u64>().ok()?,
1449                body.trim_end().trim_end_matches(';'),
1450            ))
1451        })
1452        .collect()
1453}
1454
1455/// Every `#N` reference in an entity body, in order.  For the entities audited
1456/// below that order IS the attribute order (`SURFACE_CURVE` puts its 3D curve
1457/// first and its pcurves after; `PCURVE` puts its surface first).
1458fn step_entity_refs(body: &str) -> Vec<u64> {
1459    let mut refs = Vec::new();
1460    let bytes = body.as_bytes();
1461    let mut index = 0;
1462    while index < bytes.len() {
1463        if bytes[index] == b'#' {
1464            let start = index + 1;
1465            let mut end = start;
1466            while end < bytes.len() && bytes[end].is_ascii_digit() {
1467                end += 1;
1468            }
1469            if end > start {
1470                if let Ok(id) = body[start..end].parse::<u64>() {
1471                    refs.push(id);
1472                }
1473            }
1474            index = end;
1475        } else {
1476            index += 1;
1477        }
1478    }
1479    refs
1480}
1481
1482/// Audit the curve-on-surface half of the emitted graph, on the serialized
1483/// text, for the same reason `audit_step_manifold` exists: valid in-memory
1484/// intent is not proof of a correctly written file.
1485///
1486/// An `EDGE_CURVE` whose geometry is a bare 3D curve is NOT an issue — that is
1487/// the counted, deliberate outcome of the verify-or-omit gate. What is an
1488/// issue is a malformed bundle: a `SURFACE_CURVE` with no or more than two
1489/// associated geometries, a `SEAM_CURVE` that does not carry exactly two, a
1490/// `SEAM_CURVE` whose two pcurves name DIFFERENT surfaces (it is by definition
1491/// one surface's seam), a `SURFACE_CURVE` whose two pcurves name the SAME
1492/// surface (that is a seam, and must say so), or a `PCURVE` that does not
1493/// point at both a surface and a `DEFINITIONAL_REPRESENTATION`.
1494pub fn audit_step_pcurves(step: &str) -> Vec<String> {
1495    let bodies = step_entity_bodies(step);
1496    let mut issues = Vec::new();
1497    for (id, body) in &bodies {
1498        if !body.starts_with("EDGE_CURVE(") {
1499            continue;
1500        }
1501        let refs = step_entity_refs(body);
1502        let Some(geometry) = refs.get(2) else {
1503            issues.push(format!("EDGE_CURVE #{id} has no edge_geometry"));
1504            continue;
1505        };
1506        let Some(wrapper) = bodies.get(geometry) else {
1507            issues.push(format!("EDGE_CURVE #{id} references missing #{geometry}"));
1508            continue;
1509        };
1510        let seam = wrapper.starts_with("SEAM_CURVE(");
1511        if !seam && !wrapper.starts_with("SURFACE_CURVE(") {
1512            continue;
1513        }
1514        let wrapper_refs = step_entity_refs(wrapper);
1515        let pcurves = &wrapper_refs[wrapper_refs.len().min(1)..];
1516        if pcurves.is_empty() || pcurves.len() > 2 || (seam && pcurves.len() != 2) {
1517            issues.push(format!(
1518                "#{geometry} carries {} associated geometries",
1519                pcurves.len()
1520            ));
1521            continue;
1522        }
1523        let mut surfaces = Vec::new();
1524        for pcurve in pcurves {
1525            let Some(pcurve_body) = bodies.get(pcurve) else {
1526                issues.push(format!("#{geometry} references missing #{pcurve}"));
1527                continue;
1528            };
1529            if !pcurve_body.starts_with("PCURVE(") {
1530                issues.push(format!("#{geometry} associate #{pcurve} is not a PCURVE"));
1531                continue;
1532            }
1533            let pcurve_refs = step_entity_refs(pcurve_body);
1534            let representation = pcurve_refs.get(1).and_then(|id| bodies.get(id));
1535            if !representation.is_some_and(|body| body.starts_with("DEFINITIONAL_REPRESENTATION("))
1536            {
1537                issues.push(format!(
1538                    "PCURVE #{pcurve} has no DEFINITIONAL_REPRESENTATION"
1539                ));
1540            }
1541            if let Some(surface) = pcurve_refs.first() {
1542                surfaces.push(*surface);
1543            }
1544        }
1545        if surfaces.len() == 2 && (surfaces[0] == surfaces[1]) != seam {
1546            issues.push(format!(
1547                "#{geometry} pcurves name {} surface(s) but it is a {}",
1548                if surfaces[0] == surfaces[1] { 1 } else { 2 },
1549                if seam { "SEAM_CURVE" } else { "SURFACE_CURVE" }
1550            ));
1551        }
1552    }
1553    issues.sort();
1554    issues
1555}
1556
1557/// Audit the serialized entity graph rather than assuming that valid
1558/// in-memory topology was necessarily written correctly.  Every EDGE_CURVE
1559/// in a closed shell must have exactly two ORIENTED_EDGE users with opposite
1560/// senses.
1561pub fn audit_step_manifold(step: &str) -> Vec<String> {
1562    let marker = "ORIENTED_EDGE('',*,*,#";
1563    let mut uses = HashMap::<u64, Vec<bool>>::default();
1564    for line in step.lines() {
1565        let Some(offset) = line.find(marker) else {
1566            continue;
1567        };
1568        let rest = &line[offset + marker.len()..];
1569        let digits = rest
1570            .chars()
1571            .take_while(|character| character.is_ascii_digit())
1572            .collect::<String>();
1573        let Ok(edge_id) = digits.parse::<u64>() else {
1574            continue;
1575        };
1576        let suffix = &rest[digits.len()..];
1577        let sense = suffix.starts_with(",.T.");
1578        uses.entry(edge_id).or_default().push(sense);
1579    }
1580    let mut issues = uses
1581        .into_iter()
1582        .filter_map(|(edge, senses)| {
1583            (senses.len() != 2 || senses[0] == senses[1]).then(|| {
1584                format!(
1585                    "EDGE_CURVE #{edge} has {} uses with senses {:?}",
1586                    senses.len(),
1587                    senses
1588                )
1589            })
1590        })
1591        .collect::<Vec<_>>();
1592    issues.sort();
1593    issues
1594}
1595
1596// BREP private tests: 60b37a9d721ab87b