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