Skip to main content

brep_kernel/io/
step.rs

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