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
10fn step_string(value: &str) -> String {
11    value.replace('\'', "''")
12}
13
14fn real(value: f64) -> Result<String, String> {
15    if !value.is_finite() {
16        return Err(format!("export_step: non-finite number {value}"));
17    }
18    // Analytic frame axes come from cross products that can round to -0.0;
19    // normalize so directions never print a negative zero component.
20    if value == 0.0 {
21        return Ok("0.".into());
22    }
23    if value.fract() == 0.0 && value.abs() < 1e15 {
24        return Ok(format!("{value:.0}."));
25    }
26    let mut output = format!("{value:.15}");
27    while output.ends_with('0') {
28        output.pop();
29    }
30    if output.ends_with('.') {
31        output.push('0');
32    }
33    if output == "-0.0" {
34        output = "0.0".into();
35    }
36    Ok(output)
37}
38
39fn knot_runs(knots: &[f64]) -> (Vec<f64>, Vec<usize>) {
40    let mut values = Vec::new();
41    let mut multiplicities = Vec::new();
42    for &knot in knots {
43        if values
44            .last()
45            .is_some_and(|previous: &f64| (*previous - knot).abs() <= 1e-12)
46        {
47            *multiplicities.last_mut().unwrap() += 1;
48        } else {
49            values.push(knot);
50            multiplicities.push(1);
51        }
52    }
53    (values, multiplicities)
54}
55
56pub(crate) fn edge_subcurve(edge: &EdgeRecord) -> Result<NurbsCurve, String> {
57    let [start, end] = edge.curve.domain()?;
58    let epsilon = (1e-9 * (end - start)).max(2e-9);
59    let mut curve = edge.curve.clone();
60    if edge.t0 > start + epsilon && edge.t0 < end - epsilon {
61        curve = curve.split(edge.t0)?.1;
62    }
63    let domain = curve.domain()?;
64    if edge.t1 < domain[1] - epsilon && edge.t1 > domain[0] + epsilon {
65        curve = curve.split(edge.t1)?.0;
66    }
67    Ok(curve)
68}
69
70#[derive(Default)]
71struct StepWriter {
72    lines: Vec<String>,
73}
74
75impl StepWriter {
76    fn add(&mut self, body: impl Into<String>) -> usize {
77        let id = self.lines.len() + 1;
78        self.lines.push(format!("#{id}={};", body.into()));
79        id
80    }
81
82    fn data(&self) -> String {
83        self.lines.join("\n")
84    }
85}
86
87fn write_point(writer: &mut StepWriter, point: Vec3) -> Result<usize, String> {
88    Ok(writer.add(format!(
89        "CARTESIAN_POINT('',({},{},{}))",
90        real(point.x)?,
91        real(point.y)?,
92        real(point.z)?
93    )))
94}
95
96fn id_list(ids: &[usize]) -> String {
97    format!(
98        "({})",
99        ids.iter()
100            .map(|id| format!("#{id}"))
101            .collect::<Vec<_>>()
102            .join(",")
103    )
104}
105
106fn write_direction(writer: &mut StepWriter, direction: Vec3) -> Result<usize, String> {
107    Ok(writer.add(format!(
108        "DIRECTION('',({},{},{}))",
109        real(direction.x)?,
110        real(direction.y)?,
111        real(direction.z)?
112    )))
113}
114
115fn write_placement(
116    writer: &mut StepWriter,
117    origin: Vec3,
118    axis: Vec3,
119    ref_direction: Vec3,
120) -> Result<usize, String> {
121    let origin = write_point(writer, origin)?;
122    let axis = write_direction(writer, axis)?;
123    let ref_direction = write_direction(writer, ref_direction)?;
124    Ok(writer.add(format!(
125        "AXIS2_PLACEMENT_3D('',#{origin},#{axis},#{ref_direction})"
126    )))
127}
128
129/// Emit the analytic AP214 surface entity (PLANE / CYLINDRICAL_SURFACE /
130/// CONICAL_SURFACE / SPHERICAL_SURFACE / TOROIDAL_SURFACE) for a recognized
131/// carrier, or `None` when the surface must stay a B-spline. The second value
132/// reports whether the STEP-standard orientation of the emitted entity (plane
133/// normal along the placement axis; revolution normal outward) is the REVERSE
134/// of the stored NURBS orientation, so ADVANCED_FACE can invert `same_sense`
135/// and the face normal survives the round trip. The third describes the
136/// emitted entity's own (u,v) parameterization for the pcurve writer.
137fn write_analytic_surface(
138    writer: &mut StepWriter,
139    surface: &NurbsSurface,
140) -> Result<Option<(usize, bool, EmittedSurface)>, String> {
141    let Some(analytic) = surface.analytic() else {
142        return Ok(None);
143    };
144    match analytic {
145        AnalyticSurface::Plane {
146            origin,
147            u_dir,
148            v_dir,
149            ..
150        } => {
151            // STEP planes are unbounded; the importer re-sizes the patch from
152            // the face's edges, so only origin/normal/ref matter.
153            let (Ok(normal), Ok(x_axis)) = (u_dir.cross(*v_dir).normalized(), u_dir.normalized())
154            else {
155                return Ok(None);
156            };
157            let placement = write_placement(writer, *origin, normal, x_axis)?;
158            // The reader derives the plane's second parameter axis as
159            // normal × ref_direction, so S(x,y) = origin + x·x̂ + y·ŷ with
160            // those exact vectors — an orthonormal frame regardless of how the
161            // stored patch scaled or sheared its own u_dir/v_dir.
162            Ok(Some((
163                writer.add(format!("PLANE('',#{placement})")),
164                false,
165                EmittedSurface::Plane {
166                    origin: *origin,
167                    x_axis,
168                    y_axis: normal.cross(x_axis),
169                },
170            )))
171        }
172        AnalyticSurface::RuledRevolution {
173            frame,
174            rho0,
175            rho1,
176            height,
177        } => {
178            // The recognizer allows height < 0 (descending generatrix), whose
179            // normal is the reverse of the standard outward convention the
180            // importer reconstructs; report that so the face sense compensates.
181            let flipped = *height < 0.0;
182            let radius_scale = 1.0 + rho0.abs().max(rho1.abs());
183            if (rho1 - rho0).abs() <= 1e-9 * radius_scale {
184                if *rho0 <= 0.0 {
185                    return Ok(None);
186                }
187                let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
188                return Ok(Some((
189                    writer.add(format!(
190                        "CYLINDRICAL_SURFACE('',#{placement},{})",
191                        real(*rho0)?
192                    )),
193                    flipped,
194                    EmittedSurface::Cylinder {
195                        frame: EmittedFrame {
196                            origin: frame.origin,
197                            x_axis: frame.x_axis,
198                            y_axis: frame.y_axis,
199                            axis: frame.axis,
200                            azimuth_sign: 1.0,
201                        },
202                        radius: *rho0,
203                    },
204                )));
205            }
206            // Cone. The importer re-sizes the carrier from edge samples with a
207            // 1e-4-scaled axial margin and clamps a negative extended-end
208            // radius to zero — which BENDS the rebuilt slope when the apex sits
209            // at an end of the face's axial range. Keep apex-touching cones as
210            // exact NURBS instead of exporting a distorted carrier.
211            let slope = (rho1 - rho0) / height;
212            let apex_margin = 2.0 * (1e-4 * height.abs().max(1.0) + 1e-9) * slope.abs();
213            if rho0.min(*rho1) <= apex_margin {
214                return Ok(None);
215            }
216            // Orient the placement axis so the radius grows along +axis: STEP
217            // semi-angles are positive. Radius at the placement origin stays
218            // rho0 either way because the origin is on-axis at the v = 0 base.
219            let axis = if slope >= 0.0 {
220                frame.axis
221            } else {
222                frame.axis.scale(-1.0)
223            };
224            let placement = write_placement(writer, frame.origin, axis, frame.x_axis)?;
225            Ok(Some((
226                writer.add(format!(
227                    "CONICAL_SURFACE('',#{placement},{},{})",
228                    real(*rho0)?,
229                    real(slope.abs().atan())?
230                )),
231                flipped,
232                EmittedSurface::Cone {
233                    frame: EmittedFrame {
234                        origin: frame.origin,
235                        x_axis: frame.x_axis,
236                        // Reversing the placement axis reverses the derived
237                        // second axis with it, so the emitted azimuth runs
238                        // opposite the stored carrier's u.
239                        y_axis: axis.cross(frame.x_axis),
240                        axis,
241                        azimuth_sign: if slope >= 0.0 { 1.0 } else { -1.0 },
242                    },
243                    radius: *rho0,
244                    semi_angle: slope.abs().atan(),
245                },
246            )))
247        }
248        AnalyticSurface::Sphere { frame, radius } => {
249            // Recognition template and importer reconstruction share the same
250            // south-to-north meridian construction, so the rebuild is exact.
251            let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
252            Ok(Some((
253                writer.add(format!(
254                    "SPHERICAL_SURFACE('',#{placement},{})",
255                    real(*radius)?
256                )),
257                false,
258                EmittedSurface::Sphere {
259                    frame: EmittedFrame {
260                        origin: frame.origin,
261                        x_axis: frame.x_axis,
262                        y_axis: frame.y_axis,
263                        axis: frame.axis,
264                        azimuth_sign: 1.0,
265                    },
266                    radius: *radius,
267                },
268            )))
269        }
270        AnalyticSurface::Torus {
271            frame,
272            major_radius,
273            minor_radius,
274        } => {
275            let placement = write_placement(writer, frame.origin, frame.axis, frame.x_axis)?;
276            Ok(Some((
277                writer.add(format!(
278                    "TOROIDAL_SURFACE('',#{placement},{},{})",
279                    real(*major_radius)?,
280                    real(*minor_radius)?
281                )),
282                false,
283                EmittedSurface::Torus {
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                    major_radius: *major_radius,
292                    minor_radius: *minor_radius,
293                },
294            )))
295        }
296        // No SURFACE_OF_REVOLUTION reader exists yet; general revolutions keep
297        // their exact NURBS form.
298        AnalyticSurface::Revolution { .. } => Ok(None),
299    }
300}
301
302/// A curve recognized as an exact `make_arc` product: a circular arc of
303/// `radius` about `axis`, starting at angle 0 on `x_axis` and travelling
304/// counterclockwise through `sweep` — exactly the CIRCLE parameterization the
305/// importer trims between the edge's vertices.
306struct CircularArc {
307    center: Vec3,
308    axis: Vec3,
309    x_axis: Vec3,
310    /// axis × x_axis — the second placement axis a STEP reader derives, so the
311    /// emitted parameterization is C(a) = center + r(cos a·x̂ + sin a·ŷ).
312    y_axis: Vec3,
313    radius: f64,
314    /// Total swept angle: the emitted entity's parameter range is [0, sweep].
315    sweep: f64,
316    /// Rational-quadratic span count of the kernel arc this was recognized
317    /// from — the bridge from the emitted ANGLE back to the kernel's own
318    /// parameter, which a fitted pcurve on a B-spline carrier needs.
319    spans: usize,
320}
321
322fn curve_scale(curve: &NurbsCurve) -> f64 {
323    curve
324        .control_points
325        .iter()
326        .map(|p| (p.x.abs() / p.w).max(p.y.abs() / p.w).max(p.z.abs() / p.w))
327        .fold(0.0, f64::max)
328}
329
330/// Homogeneous-net equality to a scale-relative tolerance (the same
331/// reconstruction contract analytic_surface.rs uses): matching nets mean the
332/// curves are the SAME exact rational arc, not merely close.
333fn curves_match(a: &NurbsCurve, b: &NurbsCurve, scale: f64) -> bool {
334    if a.degree != b.degree
335        || a.knots.len() != b.knots.len()
336        || a.control_points.len() != b.control_points.len()
337    {
338        return false;
339    }
340    if a.knots
341        .iter()
342        .zip(&b.knots)
343        .any(|(x, y)| (x - y).abs() > 1e-12)
344    {
345        return false;
346    }
347    let tolerance = 1e-9 * scale.max(1.0);
348    a.control_points
349        .iter()
350        .zip(&b.control_points)
351        .all(|(p, q)| {
352            (p.x - q.x).abs() <= tolerance
353                && (p.y - q.y).abs() <= tolerance
354                && (p.z - q.z).abs() <= tolerance
355                && (p.w - q.w).abs() <= 1e-9
356        })
357}
358
359/// Recognition by exact reconstruction: extract a candidate circle from three
360/// curve points, rebuild it with `make_arc`, and demand the identical net.
361/// Split subranges of a circle (whose knots are no longer the pristine
362/// make_arc pattern) are rejected and honestly stay NURBS.
363fn recognize_circular_arc(curve: &NurbsCurve) -> Option<CircularArc> {
364    if curve.degree != 2
365        || curve.control_points.len() < 3
366        || curve.control_points.len() % 2 == 0
367        || (curve.control_points.len() - 1) / 2 > 4
368    {
369        return None;
370    }
371    let [t0, t1] = curve.domain().ok()?;
372    let at = |fraction: f64| curve.evaluate(t0 + (t1 - t0) * fraction);
373    // Three points at < 74% of the sweep apart, so consecutive pairs subtend
374    // less than pi and the cross product below gives the travel direction.
375    let p0 = at(0.0).ok()?;
376    let pa = at(0.35).ok()?;
377    let pb = at(0.7).ok()?;
378    let center = circumcenter(p0, pa, pb)?;
379    let radial = p0.sub(center);
380    let radius = radial.length();
381    let scale = curve_scale(curve);
382    if radius <= 1e-9 * scale.max(1.0) {
383        return None;
384    }
385    let x_axis = radial.scale(1.0 / radius);
386    let axis = radial.cross(pa.sub(center)).normalized().ok()?;
387    let y_axis = axis.cross(x_axis);
388    let p_end = at(1.0).ok()?;
389    let sweep = if p_end.sub(p0).length() <= 1e-9 * (1.0 + radius) {
390        std::f64::consts::TAU
391    } else {
392        let closing = p_end.sub(center);
393        let mut angle = closing.dot(y_axis).atan2(closing.dot(x_axis));
394        if angle < 0.0 {
395            angle += std::f64::consts::TAU;
396        }
397        angle
398    };
399    let rebuilt = make_arc(center, x_axis, y_axis, radius, 0.0, sweep).ok()?;
400    curves_match(curve, &rebuilt, scale).then_some(CircularArc {
401        center,
402        axis,
403        x_axis,
404        y_axis,
405        radius,
406        sweep,
407        spans: (curve.control_points.len() - 1) / 2,
408    })
409}
410
411/// Emit LINE or CIRCLE for a recognized analytic edge curve (already oriented
412/// start-to-end by `edge_subcurve`), or `None` for the B-spline fallback.
413///
414/// The second value describes the parameterization ISO 10303-42 gives the
415/// entity that was written, because a pcurve on this edge has to share THAT
416/// parameter — not the kernel's knot values (see `step/pcurve.rs`).
417fn write_analytic_curve(
418    writer: &mut StepWriter,
419    curve: &NurbsCurve,
420) -> Result<Option<(usize, EmittedCurve)>, String> {
421    if curve.degree == 1
422        && curve.control_points.len() == 2
423        && curve
424            .control_points
425            .iter()
426            .all(|control| (control.w - 1.0).abs() <= 1e-12)
427    {
428        let start = curve.control_points[0].point()?;
429        let end = curve.control_points[1].point()?;
430        let Ok(direction) = end.sub(start).normalized() else {
431            return Ok(None);
432        };
433        let point = write_point(writer, start)?;
434        let step_direction = write_direction(writer, direction)?;
435        let vector = writer.add(format!(
436            "VECTOR('',#{step_direction},{})",
437            real(end.sub(start).length())?
438        ));
439        return Ok(Some((
440            writer.add(format!("LINE('',#{point},#{vector})")),
441            // The VECTOR carries the full chord length, so the STEP parameter
442            // of this line is the fraction along the chord: C(s) = start +
443            // s·(end − start) over [0, 1].
444            EmittedCurve::Line { start, end },
445        )));
446    }
447    if let Some(arc) = recognize_circular_arc(curve) {
448        // ref_direction points at the edge's start vertex and the arc runs
449        // counterclockwise about the axis, so the importer's vertex-trimmed
450        // CCW rebuild reproduces the same directed curve with sense .T.
451        let placement = write_placement(writer, arc.center, arc.axis, arc.x_axis)?;
452        return Ok(Some((
453            writer.add(format!("CIRCLE('',#{placement},{})", real(arc.radius)?)),
454            EmittedCurve::Circle {
455                center: arc.center,
456                x_axis: arc.x_axis,
457                y_axis: arc.y_axis,
458                radius: arc.radius,
459                sweep: arc.sweep,
460                spans: arc.spans,
461            },
462        )));
463    }
464    Ok(None)
465}
466
467fn write_curve(writer: &mut StepWriter, curve: &NurbsCurve) -> Result<usize, String> {
468    let points = curve
469        .control_points
470        .iter()
471        .map(|control| write_point(writer, control.point()?))
472        .collect::<Result<Vec<_>, _>>()?;
473    write_bspline_curve(writer, curve, &points)
474}
475
476/// The B_SPLINE_CURVE_WITH_KNOTS entity (or its rational complex form) over
477/// control points that have ALREADY been written — shared by the 3D edge
478/// curves and by the 2D pcurves, whose CARTESIAN_POINTs carry two coordinates
479/// instead of three but whose degree/knots/weights are written identically.
480fn write_bspline_curve(
481    writer: &mut StepWriter,
482    curve: &NurbsCurve,
483    points: &[usize],
484) -> Result<usize, String> {
485    let (knot_values, multiplicities) = knot_runs(&curve.knots);
486    let multiplicities = format!(
487        "({})",
488        multiplicities
489            .iter()
490            .map(usize::to_string)
491            .collect::<Vec<_>>()
492            .join(",")
493    );
494    let knots = format!(
495        "({})",
496        knot_values
497            .iter()
498            .map(|value| real(*value))
499            .collect::<Result<Vec<_>, _>>()?
500            .join(",")
501    );
502    let rational = curve
503        .control_points
504        .iter()
505        .any(|control| (control.w - 1.0).abs() > 1e-12);
506    if !rational {
507        return Ok(writer.add(format!(
508            "B_SPLINE_CURVE_WITH_KNOTS('',{},{},.UNSPECIFIED.,.F.,.F.,{multiplicities},{knots},.UNSPECIFIED.)",
509            curve.degree,
510            id_list(points),
511        )));
512    }
513    let weights = format!(
514        "({})",
515        curve
516            .control_points
517            .iter()
518            .map(|control| real(control.w))
519            .collect::<Result<Vec<_>, _>>()?
520            .join(",")
521    );
522    Ok(writer.add(format!(
523        "(BOUNDED_CURVE()B_SPLINE_CURVE({},{},.UNSPECIFIED.,.F.,.F.)\
524         B_SPLINE_CURVE_WITH_KNOTS({multiplicities},{knots},.UNSPECIFIED.)\
525         CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE({weights})\
526         REPRESENTATION_ITEM(''))",
527        curve.degree,
528        id_list(points),
529    )))
530}
531
532fn write_point_2d(writer: &mut StepWriter, point: [f64; 2]) -> Result<usize, String> {
533    Ok(writer.add(format!(
534        "CARTESIAN_POINT('',({},{}))",
535        real(point[0])?,
536        real(point[1])?
537    )))
538}
539
540fn write_direction_2d(writer: &mut StepWriter, direction: [f64; 2]) -> Result<usize, String> {
541    Ok(writer.add(format!(
542        "DIRECTION('',({},{}))",
543        real(direction[0])?,
544        real(direction[1])?
545    )))
546}
547
548/// Write the 2D geometry of one pcurve.
549///
550/// The `VECTOR` of a 2D LINE carries its TRUE magnitude rather than being
551/// normalized to 1: ISO 10303-42 parameterizes a line as pnt + u·(magnitude ×
552/// orientation), so the magnitude is what makes the 2D parameter equal the 3D
553/// entity's parameter exactly.  (OpenCASCADE normalizes every VECTOR it writes
554/// and then loses that agreement wherever the two speeds differ — its own cone
555/// ruling pcurves are off by cos(semi-angle) — so this is strictly the more
556/// faithful of the two conventions, and identical wherever the speeds agree.)
557fn write_pcurve_geometry(writer: &mut StepWriter, curve: &Pcurve2d) -> Result<usize, String> {
558    match curve {
559        Pcurve2d::Line { point, vector } => {
560            let magnitude = vector[0].hypot(vector[1]);
561            if magnitude <= 0.0 {
562                return Err("export_step: degenerate 2D line pcurve".into());
563            }
564            let point_id = write_point_2d(writer, *point)?;
565            let direction =
566                write_direction_2d(writer, [vector[0] / magnitude, vector[1] / magnitude])?;
567            let vector_id = writer.add(format!(
568                "VECTOR('',#{direction},{})",
569                real(magnitude)?
570            ));
571            Ok(writer.add(format!("LINE('',#{point_id},#{vector_id})")))
572        }
573        Pcurve2d::Circle {
574            center,
575            ref_direction,
576            radius,
577        } => {
578            let center_id = write_point_2d(writer, *center)?;
579            let direction = write_direction_2d(writer, *ref_direction)?;
580            let placement = writer.add(format!(
581                "AXIS2_PLACEMENT_2D('',#{center_id},#{direction})"
582            ));
583            Ok(writer.add(format!("CIRCLE('',#{placement},{})", real(*radius)?)))
584        }
585        Pcurve2d::Spline(spline) => {
586            let points = spline
587                .control_points
588                .iter()
589                .map(|control| {
590                    let point = control.point()?;
591                    write_point_2d(writer, [point.x, point.y])
592                })
593                .collect::<Result<Vec<_>, String>>()?;
594            write_bspline_curve(writer, spline, &points)
595        }
596    }
597}
598
599/// `PCURVE('', surface, DEFINITIONAL_REPRESENTATION('', (2d curve), ctx))` —
600/// the association of one 2D curve with the surface it parameterizes.
601fn write_pcurve_entity(
602    writer: &mut StepWriter,
603    surface_id: usize,
604    context_2d: usize,
605    curve: &Pcurve2d,
606) -> Result<usize, String> {
607    let geometry = write_pcurve_geometry(writer, curve)?;
608    let representation = writer.add(format!(
609        "DEFINITIONAL_REPRESENTATION('',(#{geometry}),#{context_2d})"
610    ));
611    Ok(writer.add(format!("PCURVE('',#{surface_id},#{representation})")))
612}
613
614fn write_surface(writer: &mut StepWriter, surface: &NurbsSurface) -> Result<usize, String> {
615    let rows = surface
616        .control_points
617        .iter()
618        .map(|row| {
619            row.iter()
620                .map(|control| write_point(writer, control.point()?))
621                .collect::<Result<Vec<_>, _>>()
622                .map(|ids| id_list(&ids))
623        })
624        .collect::<Result<Vec<_>, _>>()?;
625    let grid = format!("({})", rows.join(","));
626    let (u_values, u_multiplicities) = knot_runs(&surface.knots_u);
627    let (v_values, v_multiplicities) = knot_runs(&surface.knots_v);
628    let multiplicities = |values: &[usize]| {
629        format!(
630            "({})",
631            values
632                .iter()
633                .map(usize::to_string)
634                .collect::<Vec<_>>()
635                .join(",")
636        )
637    };
638    let knots = |values: &[f64]| -> Result<String, String> {
639        Ok(format!(
640            "({})",
641            values
642                .iter()
643                .map(|value| real(*value))
644                .collect::<Result<Vec<_>, _>>()?
645                .join(",")
646        ))
647    };
648    let u_mults = multiplicities(&u_multiplicities);
649    let v_mults = multiplicities(&v_multiplicities);
650    let u_knots = knots(&u_values)?;
651    let v_knots = knots(&v_values)?;
652    let rational = surface
653        .control_points
654        .iter()
655        .flatten()
656        .any(|control| (control.w - 1.0).abs() > 1e-12);
657    if !rational {
658        return Ok(writer.add(format!(
659            "B_SPLINE_SURFACE_WITH_KNOTS('',{},{},{grid},.UNSPECIFIED.,.F.,.F.,.F.,\
660             {u_mults},{v_mults},{u_knots},{v_knots},.UNSPECIFIED.)",
661            surface.degree_u, surface.degree_v,
662        )));
663    }
664    let weights = format!(
665        "({})",
666        surface
667            .control_points
668            .iter()
669            .map(|row| {
670                row.iter()
671                    .map(|control| real(control.w))
672                    .collect::<Result<Vec<_>, _>>()
673                    .map(|values| format!("({})", values.join(",")))
674            })
675            .collect::<Result<Vec<_>, _>>()?
676            .join(",")
677    );
678    Ok(writer.add(format!(
679        "(BOUNDED_SURFACE()B_SPLINE_SURFACE({},{},{grid},.UNSPECIFIED.,.F.,.F.,.F.)\
680         B_SPLINE_SURFACE_WITH_KNOTS({u_mults},{v_mults},{u_knots},{v_knots},.UNSPECIFIED.)\
681         GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE({weights})\
682         REPRESENTATION_ITEM('')SURFACE())",
683        surface.degree_u, surface.degree_v,
684    )))
685}
686
687fn write_length_unit(writer: &mut StepWriter, unit: &str) -> Result<usize, String> {
688    let normalized = unit.to_lowercase();
689    if normalized == "meter" || normalized == "metre" {
690        return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.))"));
691    }
692    if normalized == "centimeter" || normalized == "centimetre" {
693        return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))"));
694    }
695    if matches!(normalized.as_str(), "micron" | "micrometer" | "micrometre") {
696        return Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MICRO.,.METRE.))"));
697    }
698    if normalized == "inch" || normalized == "foot" {
699        let metre = writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.))");
700        let (factor, name) = if normalized == "inch" {
701            (0.0254, "INCH")
702        } else {
703            (0.3048, "FOOT")
704        };
705        let measure = writer.add(format!(
706            "LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{metre})",
707            real(factor)?
708        ));
709        return Ok(writer.add(format!(
710            "(CONVERSION_BASED_UNIT('{name}',#{measure})LENGTH_UNIT()NAMED_UNIT(*))"
711        )));
712    }
713    Ok(writer.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))"))
714}
715
716fn vertex_for(solid: &BrepSolid, id: u64) -> Result<&VertexRecord, String> {
717    solid
718        .vertices
719        .iter()
720        .find(|vertex| vertex.id == id)
721        .ok_or_else(|| format!("export_step: missing vertex {id}"))
722}
723
724fn edge_for(solid: &BrepSolid, id: u64) -> Result<&EdgeRecord, String> {
725    solid
726        .edges
727        .iter()
728        .find(|edge| edge.id == id)
729        .ok_or_else(|| format!("export_step: missing edge {id}"))
730}
731
732fn surface_key(face: &FaceRecord) -> usize {
733    face as *const FaceRecord as usize
734}
735
736/// One use of an edge by a face's loop.  Curve-on-surface geometry is written
737/// per USE — a pcurve names the surface it lives on — so the writer needs the
738/// full adjacency of an edge before it can emit that edge.
739struct CoedgeUse<'a> {
740    surface_key: usize,
741    face: &'a FaceRecord,
742    coedge: &'a CoedgeRecord,
743}
744
745/// The AP214 document plus what the writer measured while producing it.
746///
747/// The pcurve counters are the honest half of the verify-or-omit contract in
748/// `step/pcurve.rs`: a pcurve is written only when the emitted 2D geometry was
749/// proved to reproduce the emitted 3D curve, and every one that could not be
750/// proved is COUNTED here rather than guessed into the file.  A reader
751/// reprojects an omitted pcurve exactly as it must reproject every pcurve in
752/// the files this exporter wrote before curve-on-surface geometry existed.
753#[derive(Clone, Debug, Default)]
754pub struct StepExportReport {
755    /// The Part 21 text.
756    pub text: String,
757    /// `PCURVE` entities written — at most one per coedge use of a
758    /// non-degenerate edge.
759    pub pcurves_written: usize,
760    /// Coedge uses left without a pcurve because no candidate 2D geometry
761    /// verified inside the export band.
762    pub pcurves_omitted: usize,
763    /// Edges written as `SURFACE_CURVE` (their two uses sit on two surfaces).
764    pub surface_curves: usize,
765    /// Edges written as `SEAM_CURVE` (both uses on ONE surface — a periodic
766    /// seam, the case an importer otherwise has to re-detect geometrically).
767    pub seam_curves: usize,
768    /// Edges that kept a bare 3D curve for `edge_geometry`, because no pcurve
769    /// survived (or a seam lost one of the pair it is required to carry).
770    pub bare_curves: usize,
771    /// Collapsed face boundaries written as `VERTEX_LOOP` — cone apexes and
772    /// sphere poles, which this writer used to drop entirely.
773    pub vertex_loops: usize,
774    /// Largest verified ‖S(c₂d(s)) − c₃d(s)‖ among the pcurves actually
775    /// written, in model units.
776    pub max_pcurve_deviation: f64,
777    /// Largest deviation among the BEST candidate for each OMITTED pcurve —
778    /// how far the closest miss was, so an omission can be diagnosed as "a
779    /// candidate applied and missed the band by this much" rather than only
780    /// counted. Zero when nothing was omitted.
781    ///
782    /// It is infinite when ANY omission had no candidate proposed at all, which
783    /// then hides the finite misses behind it — the two omission classes share
784    /// one counter. Splitting them is a follow-up; the max is the useful half,
785    /// because it is the finite value that says whether widening the band would
786    /// have helped.
787    pub worst_omitted_deviation: f64,
788}
789
790/// Serialize exact NURBS BREP topology as an AP214 STEP Part 21 document.
791pub fn export_step(
792    solids: &[BrepSolid],
793    name: &str,
794    unit: &str,
795    timestamp: &str,
796) -> Result<String, String> {
797    export_step_report(solids, name, unit, timestamp).map(|report| report.text)
798}
799
800/// [`export_step`] plus the pcurve-coverage measurements behind the file.
801pub fn export_step_report(
802    solids: &[BrepSolid],
803    name: &str,
804    unit: &str,
805    timestamp: &str,
806) -> Result<StepExportReport, String> {
807    if solids.is_empty() {
808        return Err("export_step: at least one solid is required".into());
809    }
810    for solid in solids {
811        let policy = KernelTolerances::for_solid(solid, 1e-7);
812        let issues = solid.validate_with_tolerances(&KernelTolerances {
813            pcurve_consistency: policy.export_knit,
814            ..policy
815        });
816        if !issues.is_empty() {
817            return Err(format!("export_step: invalid solid: {issues:?}"));
818        }
819    }
820    let mut report = StepExportReport::default();
821    let mut writer = StepWriter::default();
822    let safe_name = step_string(name);
823    let application = writer.add("APPLICATION_CONTEXT('automotive design')");
824    writer.add(format!(
825        "APPLICATION_PROTOCOL_DEFINITION('','automotive_design',2010,#{application})"
826    ));
827    let product_context = writer.add(format!("PRODUCT_CONTEXT('',#{application},'mechanical')"));
828    let product = writer.add(format!(
829        "PRODUCT('{safe_name}','{safe_name}','',(#{product_context}))"
830    ));
831    let formation = writer.add(format!("PRODUCT_DEFINITION_FORMATION('','',#{product})"));
832    let definition_context = writer.add(format!(
833        "PRODUCT_DEFINITION_CONTEXT('part definition',#{application},'design')"
834    ));
835    let definition = writer.add(format!(
836        "PRODUCT_DEFINITION('design','',#{formation},#{definition_context})"
837    ));
838    let product_shape = writer.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{definition})"));
839    let length_unit = write_length_unit(&mut writer, unit)?;
840    let angle_unit = writer.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))");
841    let solid_angle_unit = writer.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())");
842    let uncertainty = writer.add(format!(
843        "UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{length_unit},'distance_accuracy_value','')"
844    ));
845    let geometry_context = writer.add(format!(
846        "(GEOMETRIC_REPRESENTATION_CONTEXT(3)\
847         GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{uncertainty}))\
848         GLOBAL_UNIT_ASSIGNED_CONTEXT((#{length_unit},#{angle_unit},#{solid_angle_unit}))\
849         REPRESENTATION_CONTEXT('',''))"
850    ));
851    // ONE parametric context for every DEFINITIONAL_REPRESENTATION in the file.
852    // It carries no unit assignment, so the importer's file-scale search
853    // (`derive_length_scale_mm`, which keys on GLOBAL_UNIT_ASSIGNED_CONTEXT /
854    // LENGTH_UNIT) cannot mistake it for the geometric context.
855    let parametric_context = writer.add(
856        "(GEOMETRIC_REPRESENTATION_CONTEXT(2)\
857         PARAMETRIC_REPRESENTATION_CONTEXT()\
858         REPRESENTATION_CONTEXT('2D SPACE',''))",
859    );
860    let origin = write_point(&mut writer, Vec3::default())?;
861    let direction_z = writer.add("DIRECTION('',(0.,0.,1.))");
862    let direction_x = writer.add("DIRECTION('',(1.,0.,0.))");
863    let axis = writer.add(format!(
864        "AXIS2_PLACEMENT_3D('',#{origin},#{direction_z},#{direction_x})"
865    ));
866
867    let mut solid_ids = Vec::new();
868    for solid in solids {
869        // The same band the export gate above already held every stored pcurve
870        // to, so a pcurve that survives here is no looser than the data it was
871        // derived from.
872        let band = KernelTolerances::for_solid(solid, 1e-7).export_knit;
873        let mut vertex_ids = HashMap::<u64, usize>::default();
874        let mut edge_ids = HashMap::<u64, usize>::default();
875        let mut surfaces = HashMap::<usize, (usize, bool, EmittedSurface)>::default();
876        for shell in &solid.shells {
877            // Pass 1 — SURFACES. A pcurve references the surface entity it
878            // parameterizes, so every surface id has to exist before the first
879            // edge is written. (Before curve-on-surface geometry the writer
880            // could emit surfaces after the loops; it no longer can.)
881            for face in &shell.faces {
882                let key = surface_key(face);
883                if surfaces.contains_key(&key) {
884                    continue;
885                }
886                let entry = match write_analytic_surface(&mut writer, &face.surface)? {
887                    Some(triple) => triple,
888                    None => (
889                        write_surface(&mut writer, &face.surface)?,
890                        false,
891                        EmittedSurface::Spline,
892                    ),
893                };
894                surfaces.insert(key, entry);
895            }
896
897            // Pass 2 — ADJACENCY. Which faces use each edge, in first-encounter
898            // order so the emitted entity numbering stays deterministic.
899            let mut edge_uses = HashMap::<u64, Vec<CoedgeUse>>::default();
900            let mut edge_order: Vec<u64> = Vec::new();
901            for face in &shell.faces {
902                for loop_record in &face.loops {
903                    for coedge in &loop_record.coedges {
904                        let edge = edge_for(solid, coedge.edge_id)?;
905                        if edge.degenerate {
906                            continue;
907                        }
908                        let uses = edge_uses.entry(edge.id).or_default();
909                        if uses.is_empty() {
910                            edge_order.push(edge.id);
911                        }
912                        uses.push(CoedgeUse {
913                            surface_key: surface_key(face),
914                            face,
915                            coedge,
916                        });
917                    }
918                }
919            }
920
921            // Pass 3 — EDGES with their curve-on-surface geometry.
922            for edge_id in &edge_order {
923                if edge_ids.contains_key(edge_id) {
924                    continue;
925                }
926                let edge = edge_for(solid, *edge_id)?;
927                let subcurve = edge_subcurve(edge)?;
928                let (curve, emitted_curve) = match write_analytic_curve(&mut writer, &subcurve)? {
929                    Some(pair) => pair,
930                    None => (
931                        write_curve(&mut writer, &subcurve)?,
932                        EmittedCurve::Spline { curve: subcurve },
933                    ),
934                };
935                let uses = &edge_uses[edge_id];
936                // Two uses on ONE surface is a periodic seam: the two halves of
937                // the edge sit on opposite domain boundaries and the reader is
938                // told so explicitly instead of having to rediscover it.
939                let seam = uses.len() == 2 && uses[0].surface_key == uses[1].surface_key;
940                let mut pcurves: Vec<(usize, Pcurve2d)> = Vec::new();
941                let mut omitted = 0usize;
942                if uses.len() == 2 {
943                    // Slot order for a seam: the FORWARD-oriented coedge first,
944                    // the reversed one second — the pairing OpenCASCADE writes
945                    // and reads (BRep_CurveOnClosedSurface's PCurve1/PCurve2).
946                    let mut ordered: Vec<&CoedgeUse> = uses.iter().collect();
947                    if seam && !ordered[0].coedge.forward {
948                        ordered.swap(0, 1);
949                    }
950                    for coedge_use in ordered {
951                        // The stored pcurve runs in LOOP direction; the edge's
952                        // geometry runs in EDGE direction. Fraction-synchronised
953                        // reversal is exact, so a reversed coedge's pcurve is
954                        // simply flipped before it is expressed.
955                        let oriented = if coedge_use.coedge.forward {
956                            coedge_use.coedge.pcurve.clone()
957                        } else {
958                            coedge_use.coedge.pcurve.reversed()?
959                        };
960                        let (surface_id, _, emitted_surface) = &surfaces[&coedge_use.surface_key];
961                        let outcome = build_pcurve(
962                            &coedge_use.face.surface,
963                            emitted_surface,
964                            &emitted_curve,
965                            &oriented,
966                            band,
967                        )?;
968                        match outcome.curve {
969                            Some(curve_2d) => {
970                                report.max_pcurve_deviation =
971                                    report.max_pcurve_deviation.max(outcome.deviation);
972                                pcurves.push((*surface_id, curve_2d));
973                            }
974                            None => {
975                                omitted += 1;
976                                if outcome.deviation.is_finite() {
977                                    report.worst_omitted_deviation =
978                                        report.worst_omitted_deviation.max(outcome.deviation);
979                                } else {
980                                    report.worst_omitted_deviation = f64::INFINITY;
981                                }
982                            }
983                        }
984                    }
985                }
986                // A SEAM_CURVE is required to carry BOTH pcurves on the one
987                // surface, so a seam that lost one falls all the way back to a
988                // bare 3D curve rather than to a half-described seam.
989                if seam && pcurves.len() != 2 {
990                    omitted += pcurves.len();
991                    pcurves.clear();
992                }
993                report.pcurves_omitted += omitted;
994                report.pcurves_written += pcurves.len();
995                let geometry = if pcurves.is_empty() {
996                    report.bare_curves += 1;
997                    curve
998                } else {
999                    let ids = pcurves
1000                        .iter()
1001                        .map(|(surface_id, curve_2d)| {
1002                            write_pcurve_entity(
1003                                &mut writer,
1004                                *surface_id,
1005                                parametric_context,
1006                                curve_2d,
1007                            )
1008                        })
1009                        .collect::<Result<Vec<_>, String>>()?;
1010                    let keyword = if seam {
1011                        report.seam_curves += 1;
1012                        "SEAM_CURVE"
1013                    } else {
1014                        report.surface_curves += 1;
1015                        "SURFACE_CURVE"
1016                    };
1017                    // master_representation is .CURVE_3D.: our 3D curves are
1018                    // the exact authority the whole kernel and the export gate
1019                    // treat them as, and the pcurves above are verified AGAINST
1020                    // them. Promoting an approximation to master would be the
1021                    // one claim this writer must not make.
1022                    writer.add(format!(
1023                        "{keyword}('',#{curve},{},.CURVE_3D.)",
1024                        id_list(&ids)
1025                    ))
1026                };
1027                let start = vertex_step_id(&mut writer, &mut vertex_ids, solid, edge.start_vertex_id)?;
1028                let end = vertex_step_id(&mut writer, &mut vertex_ids, solid, edge.end_vertex_id)?;
1029                let step_id =
1030                    writer.add(format!("EDGE_CURVE('',#{start},#{end},#{geometry},.T.)"));
1031                edge_ids.insert(edge.id, step_id);
1032            }
1033
1034            // Pass 4 — LOOPS and FACES over the ids the passes above fixed.
1035            let mut face_ids = Vec::new();
1036            for face in &shell.faces {
1037                let mut bound_ids = Vec::new();
1038                for (loop_index, loop_record) in face.loops.iter().enumerate() {
1039                    let mut oriented_edges = Vec::new();
1040                    for coedge in &loop_record.coedges {
1041                        let edge = edge_for(solid, coedge.edge_id)?;
1042                        if edge.degenerate {
1043                            continue;
1044                        }
1045                        let edge_id = *edge_ids
1046                            .get(&edge.id)
1047                            .ok_or_else(|| format!("export_step: unwritten edge {}", edge.id))?;
1048                        let orientation = if coedge.forward { ".T." } else { ".F." };
1049                        oriented_edges.push(
1050                            writer.add(format!("ORIENTED_EDGE('',*,*,#{edge_id},{orientation})")),
1051                        );
1052                    }
1053                    let kind = if loop_index == 0 {
1054                        "FACE_OUTER_BOUND"
1055                    } else {
1056                        "FACE_BOUND"
1057                    };
1058                    if oriented_edges.is_empty() {
1059                        // A bound left empty by the degenerate-edge skip is a
1060                        // COLLAPSED boundary — a cone apex, a sphere pole —
1061                        // and AP214 spells that `VERTEX_LOOP`, not nothing.
1062                        // Dropping it (what this writer did before) deletes the
1063                        // apex from the face's parameter domain, so an imported
1064                        // pointed cone lost its apex bound on re-export and
1065                        // every reader had to re-synthesise it from the
1066                        // surface's own degeneracy. A MIXED loop keeps the
1067                        // skip: that is OCCT's own write convention, both our
1068                        // importer and OCC's ShapeFix rebuild those, and the
1069                        // advanced-face conformance class permits vertex loops
1070                        // but not zero-length edge curves.
1071                        let Some(coedge) = loop_record.coedges.first() else {
1072                            continue;
1073                        };
1074                        let collapsed = edge_for(solid, coedge.edge_id)?;
1075                        let vertex = vertex_step_id(
1076                            &mut writer,
1077                            &mut vertex_ids,
1078                            solid,
1079                            collapsed.start_vertex_id,
1080                        )?;
1081                        let vertex_loop = writer.add(format!("VERTEX_LOOP('',#{vertex})"));
1082                        report.vertex_loops += 1;
1083                        bound_ids.push(writer.add(format!("{kind}('',#{vertex_loop},.T.)")));
1084                        continue;
1085                    }
1086                    let edge_loop =
1087                        writer.add(format!("EDGE_LOOP('',{})", id_list(&oriented_edges)));
1088                    bound_ids.push(writer.add(format!("{kind}('',#{edge_loop},.T.)")));
1089                }
1090                let (surface, flipped, _) = surfaces[&surface_key(face)];
1091                // `flipped` analytic entities are written with the reverse of
1092                // the stored NURBS orientation, so invert the flag to keep the
1093                // face normal identical through the round trip.
1094                let sense = if face.same_sense != flipped {
1095                    ".T."
1096                } else {
1097                    ".F."
1098                };
1099                face_ids.push(writer.add(format!(
1100                    "ADVANCED_FACE('',{},#{surface},{sense})",
1101                    id_list(&bound_ids)
1102                )));
1103            }
1104            let closed_shell = writer.add(format!("CLOSED_SHELL('',{})", id_list(&face_ids)));
1105            solid_ids.push(writer.add(format!(
1106                "MANIFOLD_SOLID_BREP('{safe_name}',#{closed_shell})"
1107            )));
1108        }
1109    }
1110    let mut items = vec![axis];
1111    items.extend(&solid_ids);
1112    let representation = writer.add(format!(
1113        "ADVANCED_BREP_SHAPE_REPRESENTATION('',{},#{geometry_context})",
1114        id_list(&items)
1115    ));
1116    writer.add(format!(
1117        "SHAPE_DEFINITION_REPRESENTATION(#{product_shape},#{representation})"
1118    ));
1119    let safe_timestamp = step_string(timestamp);
1120    let output = [
1121        "ISO-10303-21;".to_string(),
1122        "HEADER;".to_string(),
1123        "FILE_DESCRIPTION((''),'2;1');".to_string(),
1124        format!(
1125            "FILE_NAME('{safe_name}.step','{safe_timestamp}',(''),(''),'brep-kernel-rs','brep-kernel-rs','');"
1126        ),
1127        "FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));".to_string(),
1128        "ENDSEC;".to_string(),
1129        "DATA;".to_string(),
1130        writer.data(),
1131        "ENDSEC;".to_string(),
1132        "END-ISO-10303-21;".to_string(),
1133        String::new(),
1134    ]
1135    .join("\n");
1136    let manifold_issues = audit_step_manifold(&output);
1137    if !manifold_issues.is_empty() {
1138        return Err(format!(
1139            "export_step: emitted AP214 manifold audit failed: {}",
1140            manifold_issues.join("; ")
1141        ));
1142    }
1143    let pcurve_issues = audit_step_pcurves(&output);
1144    if !pcurve_issues.is_empty() {
1145        return Err(format!(
1146            "export_step: emitted AP214 pcurve audit failed: {}",
1147            pcurve_issues.join("; ")
1148        ));
1149    }
1150    report.text = output;
1151    Ok(report)
1152}
1153
1154/// VERTEX_POINT for a kernel vertex, written once per solid.
1155fn vertex_step_id(
1156    writer: &mut StepWriter,
1157    vertex_ids: &mut HashMap<u64, usize>,
1158    solid: &BrepSolid,
1159    id: u64,
1160) -> Result<usize, String> {
1161    if let Some(step_id) = vertex_ids.get(&id) {
1162        return Ok(*step_id);
1163    }
1164    let point = write_point(writer, vertex_for(solid, id)?.point)?;
1165    let step_id = writer.add(format!("VERTEX_POINT('',#{point})"));
1166    vertex_ids.insert(id, step_id);
1167    Ok(step_id)
1168}
1169
1170/// Entity id -> body, for the serialized-text audits.  One entity per line is
1171/// this writer's own invariant, so the "parse" is a split.
1172fn step_entity_bodies(step: &str) -> HashMap<u64, &str> {
1173    step.lines()
1174        .filter_map(|line| {
1175            let rest = line.strip_prefix('#')?;
1176            let (digits, body) = rest.split_once('=')?;
1177            Some((
1178                digits.parse::<u64>().ok()?,
1179                body.trim_end().trim_end_matches(';'),
1180            ))
1181        })
1182        .collect()
1183}
1184
1185/// Every `#N` reference in an entity body, in order.  For the entities audited
1186/// below that order IS the attribute order (`SURFACE_CURVE` puts its 3D curve
1187/// first and its pcurves after; `PCURVE` puts its surface first).
1188fn step_entity_refs(body: &str) -> Vec<u64> {
1189    let mut refs = Vec::new();
1190    let bytes = body.as_bytes();
1191    let mut index = 0;
1192    while index < bytes.len() {
1193        if bytes[index] == b'#' {
1194            let start = index + 1;
1195            let mut end = start;
1196            while end < bytes.len() && bytes[end].is_ascii_digit() {
1197                end += 1;
1198            }
1199            if end > start {
1200                if let Ok(id) = body[start..end].parse::<u64>() {
1201                    refs.push(id);
1202                }
1203            }
1204            index = end;
1205        } else {
1206            index += 1;
1207        }
1208    }
1209    refs
1210}
1211
1212/// Audit the curve-on-surface half of the emitted graph, on the serialized
1213/// text, for the same reason `audit_step_manifold` exists: valid in-memory
1214/// intent is not proof of a correctly written file.
1215///
1216/// An `EDGE_CURVE` whose geometry is a bare 3D curve is NOT an issue — that is
1217/// the counted, deliberate outcome of the verify-or-omit gate. What is an
1218/// issue is a malformed bundle: a `SURFACE_CURVE` with no or more than two
1219/// associated geometries, a `SEAM_CURVE` that does not carry exactly two, a
1220/// `SEAM_CURVE` whose two pcurves name DIFFERENT surfaces (it is by definition
1221/// one surface's seam), a `SURFACE_CURVE` whose two pcurves name the SAME
1222/// surface (that is a seam, and must say so), or a `PCURVE` that does not
1223/// point at both a surface and a `DEFINITIONAL_REPRESENTATION`.
1224pub fn audit_step_pcurves(step: &str) -> Vec<String> {
1225    let bodies = step_entity_bodies(step);
1226    let mut issues = Vec::new();
1227    for (id, body) in &bodies {
1228        if !body.starts_with("EDGE_CURVE(") {
1229            continue;
1230        }
1231        let refs = step_entity_refs(body);
1232        let Some(geometry) = refs.get(2) else {
1233            issues.push(format!("EDGE_CURVE #{id} has no edge_geometry"));
1234            continue;
1235        };
1236        let Some(wrapper) = bodies.get(geometry) else {
1237            issues.push(format!("EDGE_CURVE #{id} references missing #{geometry}"));
1238            continue;
1239        };
1240        let seam = wrapper.starts_with("SEAM_CURVE(");
1241        if !seam && !wrapper.starts_with("SURFACE_CURVE(") {
1242            continue;
1243        }
1244        let wrapper_refs = step_entity_refs(wrapper);
1245        let pcurves = &wrapper_refs[wrapper_refs.len().min(1)..];
1246        if pcurves.is_empty() || pcurves.len() > 2 || (seam && pcurves.len() != 2) {
1247            issues.push(format!(
1248                "#{geometry} carries {} associated geometries",
1249                pcurves.len()
1250            ));
1251            continue;
1252        }
1253        let mut surfaces = Vec::new();
1254        for pcurve in pcurves {
1255            let Some(pcurve_body) = bodies.get(pcurve) else {
1256                issues.push(format!("#{geometry} references missing #{pcurve}"));
1257                continue;
1258            };
1259            if !pcurve_body.starts_with("PCURVE(") {
1260                issues.push(format!("#{geometry} associate #{pcurve} is not a PCURVE"));
1261                continue;
1262            }
1263            let pcurve_refs = step_entity_refs(pcurve_body);
1264            let representation = pcurve_refs.get(1).and_then(|id| bodies.get(id));
1265            if !representation.is_some_and(|body| body.starts_with("DEFINITIONAL_REPRESENTATION("))
1266            {
1267                issues.push(format!(
1268                    "PCURVE #{pcurve} has no DEFINITIONAL_REPRESENTATION"
1269                ));
1270            }
1271            if let Some(surface) = pcurve_refs.first() {
1272                surfaces.push(*surface);
1273            }
1274        }
1275        if surfaces.len() == 2 && (surfaces[0] == surfaces[1]) != seam {
1276            issues.push(format!(
1277                "#{geometry} pcurves name {} surface(s) but it is a {}",
1278                if surfaces[0] == surfaces[1] { 1 } else { 2 },
1279                if seam { "SEAM_CURVE" } else { "SURFACE_CURVE" }
1280            ));
1281        }
1282    }
1283    issues.sort();
1284    issues
1285}
1286
1287/// Audit the serialized entity graph rather than assuming that valid
1288/// in-memory topology was necessarily written correctly.  Every EDGE_CURVE
1289/// in a closed shell must have exactly two ORIENTED_EDGE users with opposite
1290/// senses.
1291pub fn audit_step_manifold(step: &str) -> Vec<String> {
1292    let marker = "ORIENTED_EDGE('',*,*,#";
1293    let mut uses = HashMap::<u64, Vec<bool>>::default();
1294    for line in step.lines() {
1295        let Some(offset) = line.find(marker) else {
1296            continue;
1297        };
1298        let rest = &line[offset + marker.len()..];
1299        let digits = rest
1300            .chars()
1301            .take_while(|character| character.is_ascii_digit())
1302            .collect::<String>();
1303        let Ok(edge_id) = digits.parse::<u64>() else {
1304            continue;
1305        };
1306        let suffix = &rest[digits.len()..];
1307        let sense = suffix.starts_with(",.T.");
1308        uses.entry(edge_id).or_default().push(sense);
1309    }
1310    let mut issues = uses
1311        .into_iter()
1312        .filter_map(|(edge, senses)| {
1313            (senses.len() != 2 || senses[0] == senses[1]).then(|| {
1314                format!(
1315                    "EDGE_CURVE #{edge} has {} uses with senses {:?}",
1316                    senses.len(),
1317                    senses
1318                )
1319            })
1320        })
1321        .collect::<Vec<_>>();
1322    issues.sort();
1323    issues
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329    use crate::{
1330        boolean_operation, import_step, make_box_brep, make_cone_brep, make_cylinder_brep,
1331        make_cylinder_surface, make_sphere_brep, make_torus_brep, solid_mass_properties,
1332        BooleanOperation, BooleanOptions,
1333    };
1334
1335    #[test]
1336    fn box_step_contains_exact_manifold_topology() {
1337        let box_solid = make_box_brep(Vec3::default(), 2.0, 3.0, 4.0).unwrap();
1338        let step = export_step(&[box_solid], "box", "millimeter", "2026-07-27T00:00:00").unwrap();
1339        assert!(step.starts_with("ISO-10303-21;\nHEADER;"));
1340        assert!(audit_step_manifold(&step).is_empty());
1341        assert!(step.contains("MANIFOLD_SOLID_BREP('box'"));
1342        assert_eq!(step.matches("ADVANCED_FACE(").count(), 6);
1343        assert_eq!(step.matches("EDGE_CURVE(").count(), 12);
1344        assert!(step.ends_with("END-ISO-10303-21;\n"));
1345    }
1346
1347    #[test]
1348    fn step_manifold_audit_rejects_single_and_same_sense_uses() {
1349        let single = "#1=ORIENTED_EDGE('',*,*,#9,.T.);";
1350        assert_eq!(audit_step_manifold(single).len(), 1);
1351        let same = "#1=ORIENTED_EDGE('',*,*,#9,.T.);\n\
1352                    #2=ORIENTED_EDGE('',*,*,#9,.T.);";
1353        assert_eq!(audit_step_manifold(same).len(), 1);
1354        let good = "#1=ORIENTED_EDGE('',*,*,#9,.T.);\n\
1355                    #2=ORIENTED_EDGE('',*,*,#9,.F.);";
1356        assert!(audit_step_manifold(good).is_empty());
1357    }
1358
1359    #[test]
1360    fn unrecognized_surfaces_and_curves_still_write_rational_complex_entities() {
1361        // The all-NURBS fallback writers must stay intact for carriers no
1362        // analytic entity covers (general revolutions, split arc subranges).
1363        let mut writer = StepWriter::default();
1364        let cylinder =
1365            make_cylinder_surface(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
1366        write_surface(&mut writer, &cylinder).unwrap();
1367        let split_arc = make_arc(
1368            Vec3::default(),
1369            Vec3::new(1.0, 0.0, 0.0),
1370            Vec3::new(0.0, 1.0, 0.0),
1371            2.0,
1372            0.0,
1373            std::f64::consts::TAU,
1374        )
1375        .unwrap()
1376        .split(0.37)
1377        .unwrap()
1378        .1;
1379        assert!(
1380            recognize_circular_arc(&split_arc).is_none(),
1381            "a split subrange is not the pristine make_arc net"
1382        );
1383        assert!(write_analytic_curve(&mut writer, &split_arc)
1384            .unwrap()
1385            .is_none());
1386        write_curve(&mut writer, &split_arc).unwrap();
1387        let data = writer.data();
1388        assert!(data.contains("RATIONAL_B_SPLINE_SURFACE"));
1389        assert!(data.contains("RATIONAL_B_SPLINE_CURVE"));
1390    }
1391
1392    /// Export → assert the analytic entities appear (and, when the solid is
1393    /// fully analytic, that NO B-spline entity remains) → import → validate,
1394    /// match volume to 1e-6, and re-recognize every face's carrier.
1395    fn assert_analytic_round_trip(
1396        label: &str,
1397        original: &BrepSolid,
1398        expected_markers: &[&str],
1399        forbid_nurbs: bool,
1400    ) -> BrepSolid {
1401        let step = export_step(std::slice::from_ref(original), label, "millimeter", "fixed")
1402            .expect("export");
1403        for marker in expected_markers {
1404            assert!(step.contains(marker), "{label}: missing {marker}");
1405        }
1406        if forbid_nurbs {
1407            assert!(
1408                !step.contains("B_SPLINE"),
1409                "{label}: expected a fully analytic export"
1410            );
1411        }
1412        assert!(audit_step_manifold(&step).is_empty(), "{label}: audit");
1413        let imported = import_step(&step).expect("import");
1414        assert_eq!(imported.len(), 1, "{label}: one solid");
1415        let solid = imported.into_iter().next().unwrap();
1416        assert!(
1417            solid.validate().is_empty(),
1418            "{label}: imported solid invalid: {:?}",
1419            solid.validate()
1420        );
1421        let original_volume = solid_mass_properties(original).unwrap().volume;
1422        let volume = solid_mass_properties(&solid).unwrap().volume;
1423        let relative = ((volume - original_volume) / original_volume).abs();
1424        assert!(
1425            relative < 1e-6,
1426            "{label}: volume {volume} vs {original_volume} (rel {relative:.3e})"
1427        );
1428        for shell in &solid.shells {
1429            for face in &shell.faces {
1430                assert!(
1431                    face.surface.analytic().is_some(),
1432                    "{label}: imported face {} did not re-recognize as analytic",
1433                    face.id
1434                );
1435            }
1436        }
1437        solid
1438    }
1439
1440    #[test]
1441    fn box_round_trips_through_plane_and_line_entities() {
1442        let solid = make_box_brep(Vec3::new(-1.0, 0.5, 2.0), 2.0, 3.0, 4.0).unwrap();
1443        let step = export_step(std::slice::from_ref(&solid), "box", "millimeter", "fixed").unwrap();
1444        assert_eq!(step.matches("PLANE(").count(), 6);
1445        // 12 three-dimensional edge lines plus the 24 two-dimensional pcurve
1446        // lines that now carry each edge on both of the planes it bounds.
1447        assert_eq!(step.matches("LINE(").count(), 36);
1448        assert_analytic_round_trip("box", &solid, &["PLANE(", "LINE("], true);
1449    }
1450
1451    /// Curve-on-surface coverage on the case where every pcurve is exact: a
1452    /// box's edges are straight lines on planes, so each of the 12 edges goes
1453    /// out as a SURFACE_CURVE carrying the 2D LINE it traces on each of its
1454    /// two planes — no B-spline anywhere, nothing omitted.
1455    #[test]
1456    fn box_pcurves_cover_every_edge() {
1457        let solid = make_box_brep(Vec3::new(-1.0, 0.5, 2.0), 2.0, 3.0, 4.0).unwrap();
1458        let report =
1459            export_step_report(std::slice::from_ref(&solid), "box", "millimeter", "fixed").unwrap();
1460        assert_eq!(report.pcurves_written, 24);
1461        assert_eq!(report.pcurves_omitted, 0);
1462        assert_eq!(report.surface_curves, 12);
1463        assert_eq!(report.seam_curves, 0);
1464        assert_eq!(report.bare_curves, 0);
1465        assert_eq!(report.text.matches("SURFACE_CURVE(").count(), 12);
1466        assert_eq!(report.text.matches("PCURVE(").count(), 24);
1467        assert_eq!(
1468            report.text.matches("DEFINITIONAL_REPRESENTATION(").count(),
1469            24
1470        );
1471        // ONE parametric context shared by all 24 definitional representations.
1472        assert_eq!(
1473            report
1474                .text
1475                .matches("PARAMETRIC_REPRESENTATION_CONTEXT()")
1476                .count(),
1477            1
1478        );
1479        assert!(
1480            !report.text.contains("B_SPLINE"),
1481            "an all-planar solid must stay B-spline-free on both sides"
1482        );
1483        assert!(audit_step_pcurves(&report.text).is_empty());
1484        // Every plane frame here is axis aligned, so reconstructing a sampled
1485        // point through (p−o)·x̂, (p−o)·ŷ and back is exact in binary: the
1486        // measured worst deviation is 0.
1487        assert_eq!(report.max_pcurve_deviation, 0.0);
1488    }
1489
1490    /// The same coverage on a solid with NO axis-aligned plane frame, so the
1491    /// verify gate reports a real floating-point residual rather than an
1492    /// exact-by-luck zero. Observed worst deviation 3.1e-15 mm; the band this
1493    /// is checked against is `export_knit` = 4e-3 mm, twelve orders of
1494    /// magnitude larger, so the assertion below is deliberately tight at 1e-13
1495    /// (32x the observation) instead of at the shipping band.
1496    #[test]
1497    fn rotated_box_pcurves_stay_exact_off_axis() {
1498        let (sin, cos) = 0.7_f64.sin_cos();
1499        let axis = Vec3::new(0.3, 0.7, 0.2).normalized().unwrap();
1500        let (ax, ay, az) = (axis.x, axis.y, axis.z);
1501        let one = 1.0 - cos;
1502        let rotation = crate::AffineTransform::new([
1503            cos + ax * ax * one,
1504            ax * ay * one - az * sin,
1505            ax * az * one + ay * sin,
1506            0.0,
1507            ay * ax * one + az * sin,
1508            cos + ay * ay * one,
1509            ay * az * one - ax * sin,
1510            0.0,
1511            az * ax * one - ay * sin,
1512            az * ay * one + ax * sin,
1513            cos + az * az * one,
1514            0.0,
1515            0.0,
1516            0.0,
1517            0.0,
1518            1.0,
1519        ])
1520        .unwrap();
1521        let solid = crate::transform_brep(
1522            &make_box_brep(Vec3::new(-1.0, 0.5, 2.0), 2.0, 3.0, 4.0).unwrap(),
1523            rotation,
1524            false,
1525        )
1526        .unwrap();
1527        let report =
1528            export_step_report(std::slice::from_ref(&solid), "tilted", "millimeter", "fixed")
1529                .unwrap();
1530        assert_eq!(report.pcurves_written, 24);
1531        assert_eq!(report.pcurves_omitted, 0);
1532        assert!(
1533            report.max_pcurve_deviation < 1e-13,
1534            "off-axis pcurve deviation {:.3e} exceeded 1e-13",
1535            report.max_pcurve_deviation
1536        );
1537        assert!(audit_step_pcurves(&report.text).is_empty());
1538        assert!(import_step(&report.text).is_ok());
1539    }
1540
1541    /// Every edge of a cylinder and a frustum now carries curve-on-surface
1542    /// geometry, and every piece of it is EXACT: rim circles become 2D lines
1543    /// on the revolution (the STEP angle is the surface's own u) and 2D
1544    /// circles on the cap planes, the seam ruling becomes a pair of 2D lines
1545    /// on one surface. Nothing is omitted and no B-spline appears, so the
1546    /// analytic exports stay analytic on both sides.
1547    ///
1548    /// `frustum` shrinks along +axis and `frustum_growing` grows: the shrinking
1549    /// one is written with a REVERSED placement axis (STEP semi-angles are
1550    /// positive), which reverses the emitted azimuth against the stored
1551    /// carrier's u. Both are here because only the reversed one exercises that.
1552    #[test]
1553    fn revolution_carriers_cover_every_edge_exactly() {
1554        let axis = Vec3::new(0.0, 0.0, 1.0);
1555        for (label, solid) in [
1556            (
1557                "cylinder",
1558                make_cylinder_brep(Vec3::new(1.0, -2.0, 0.5), axis, 2.0, 5.0).unwrap(),
1559            ),
1560            (
1561                "cylinder_reversed",
1562                make_cylinder_brep(Vec3::new(0.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0), 2.0, 5.0)
1563                    .unwrap(),
1564            ),
1565            (
1566                "frustum",
1567                make_cone_brep(Vec3::new(0.5, 0.5, -1.0), axis, 3.0, 1.5, 5.0).unwrap(),
1568            ),
1569            (
1570                "frustum_growing",
1571                make_cone_brep(Vec3::new(0.5, 0.5, -1.0), axis, 1.5, 3.0, 5.0).unwrap(),
1572            ),
1573        ] {
1574            let report =
1575                export_step_report(std::slice::from_ref(&solid), label, "millimeter", "fixed")
1576                    .unwrap();
1577            // Two rim circles (two uses each) plus the seam ruling (two uses).
1578            assert_eq!(report.pcurves_written, 6, "{label}: written");
1579            assert_eq!(report.pcurves_omitted, 0, "{label}: omitted");
1580            assert_eq!(report.surface_curves, 2, "{label}: surface curves");
1581            assert_eq!(report.seam_curves, 1, "{label}: seam curves");
1582            assert_eq!(report.bare_curves, 0, "{label}: bare curves");
1583            // Both cap rims: a 2D CIRCLE apiece on the cap planes.
1584            assert_eq!(
1585                report.text.matches("AXIS2_PLACEMENT_2D(").count(),
1586                2,
1587                "{label}: 2D circles"
1588            );
1589            assert!(
1590                !report.text.contains("B_SPLINE"),
1591                "{label}: an analytic solid must export analytic on both sides"
1592            );
1593            assert!(audit_step_pcurves(&report.text).is_empty(), "{label}: audit");
1594            // Observed worst deviations: cylinder 8.9e-16, reversed 6.3e-16,
1595            // frustum 2.1e-15, growing frustum 1.9e-15 mm. The shipping band is
1596            // export_knit = 4e-3 mm; assert at 1e-12 (about 500x the worst
1597            // observation) so this stays a claim about exactness.
1598            assert!(
1599                report.max_pcurve_deviation < 1e-12,
1600                "{label}: pcurve deviation {:.3e} exceeded 1e-12",
1601                report.max_pcurve_deviation
1602            );
1603        }
1604    }
1605
1606    /// A sphere's only non-degenerate edge is its pole-to-pole seam, used twice
1607    /// by the one spherical face. Both ends of that edge sit ON the axis, where
1608    /// the azimuth is undefined and the surface collapses — the sampler fills
1609    /// those from a neighbouring sample, and the result is still a pair of
1610    /// exact 2D lines at u = 0 and u = 2π.
1611    #[test]
1612    fn sphere_pole_and_seam() {
1613        let solid =
1614            make_sphere_brep(Vec3::new(2.0, 1.0, -1.0), 3.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1615        let report =
1616            export_step_report(std::slice::from_ref(&solid), "sphere", "millimeter", "fixed")
1617                .unwrap();
1618        assert_eq!(report.seam_curves, 1);
1619        assert_eq!(report.pcurves_written, 2);
1620        assert_eq!(report.pcurves_omitted, 0);
1621        assert_eq!(report.text.matches("SEAM_CURVE(").count(), 1);
1622        assert!(!report.text.contains("B_SPLINE"));
1623        assert!(audit_step_pcurves(&report.text).is_empty());
1624        // Observed 4.768e-15 mm on a radius-3 sphere; band 1e-12 is 210x that.
1625        assert!(
1626            report.max_pcurve_deviation < 1e-12,
1627            "sphere pcurve deviation {:.3e}",
1628            report.max_pcurve_deviation
1629        );
1630    }
1631
1632    /// A torus is closed in BOTH directions, so it has two seam edges and no
1633    /// ordinary surface curve at all — the case where a reader with no pcurves
1634    /// has the most to re-derive.
1635    #[test]
1636    fn torus_seams() {
1637        let solid =
1638            make_torus_brep(Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, 1.0), 5.0, 1.5).unwrap();
1639        let report =
1640            export_step_report(std::slice::from_ref(&solid), "torus", "millimeter", "fixed")
1641                .unwrap();
1642        assert_eq!(report.seam_curves, 2);
1643        assert_eq!(report.surface_curves, 0);
1644        assert_eq!(report.pcurves_written, 4);
1645        assert_eq!(report.pcurves_omitted, 0);
1646        assert!(!report.text.contains("B_SPLINE"));
1647        assert!(audit_step_pcurves(&report.text).is_empty());
1648        // Observed 1.592e-15 mm; band 1e-12 is 630x that.
1649        assert!(
1650            report.max_pcurve_deviation < 1e-12,
1651            "torus pcurve deviation {:.3e}",
1652            report.max_pcurve_deviation
1653        );
1654    }
1655
1656    /// Every number in a body of a written entity, ignoring `#N` references.
1657    /// Only tokens containing a decimal point count, which is exactly what
1658    /// `real` emits and never an entity's dimension count or spline degree.
1659    fn step_numbers(body: &str) -> Vec<f64> {
1660        let characters: Vec<char> = body.chars().collect();
1661        let mut values = Vec::new();
1662        let mut index = 0;
1663        while index < characters.len() {
1664            if characters[index] == '#' {
1665                index += 1;
1666                while index < characters.len() && characters[index].is_ascii_digit() {
1667                    index += 1;
1668                }
1669                continue;
1670            }
1671            let signed = characters[index] == '-'
1672                && index + 1 < characters.len()
1673                && characters[index + 1].is_ascii_digit();
1674            if !characters[index].is_ascii_digit() && !signed {
1675                index += 1;
1676                continue;
1677            }
1678            let start = index;
1679            if signed {
1680                index += 1;
1681            }
1682            while index < characters.len() && characters[index].is_ascii_digit() {
1683                index += 1;
1684            }
1685            if index < characters.len() && characters[index] == '.' {
1686                index += 1;
1687                while index < characters.len() && characters[index].is_ascii_digit() {
1688                    index += 1;
1689                }
1690                values.push(
1691                    characters[start..index]
1692                        .iter()
1693                        .collect::<String>()
1694                        .parse()
1695                        .expect("number"),
1696                );
1697            }
1698        }
1699        values
1700    }
1701
1702    /// Read the emitted file back and re-evaluate it against ISO 10303-42's own
1703    /// formulas — written HERE, from the standard, not shared with the writer —
1704    /// to prove the 2D and 3D geometry of an edge agree at EQUAL parameters.
1705    ///
1706    /// This is the check the internal verify-or-omit gate cannot make about
1707    /// itself: if the writer's evaluators were self-consistently wrong, the gate
1708    /// would pass every candidate and this test would fail.
1709    #[test]
1710    fn pcurve_parameter_shared_with_analytic_curve() {
1711        let solid =
1712            make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
1713        let step =
1714            export_step(std::slice::from_ref(&solid), "cylinder", "millimeter", "fixed").unwrap();
1715        let bodies = step_entity_bodies(&step);
1716        let body = |id: u64| -> &str { bodies[&id] };
1717        let refs = |id: u64| step_entity_refs(body(id));
1718        let numbers = |id: u64| step_numbers(body(id));
1719        let vector3 = |id: u64| {
1720            let values = numbers(id);
1721            Vec3::new(values[0], values[1], values[2])
1722        };
1723
1724        // The cylindrical surface: placement (origin, axis, ref) + radius.
1725        let surface_id = bodies
1726            .iter()
1727            .find(|(_, text)| text.starts_with("CYLINDRICAL_SURFACE("))
1728            .map(|(id, _)| *id)
1729            .expect("cylindrical surface");
1730        let radius = numbers(surface_id)[0];
1731        let placement = refs(refs(surface_id)[0]);
1732        let origin = vector3(placement[0]);
1733        let axis = vector3(placement[1]);
1734        let x_axis = vector3(placement[2]);
1735        let y_axis = axis.cross(x_axis);
1736        // ISO 10303-42 cylindrical_surface.
1737        let evaluate = |u: f64, v: f64| {
1738            origin
1739                .add(x_axis.scale(radius * u.cos()))
1740                .add(y_axis.scale(radius * u.sin()))
1741                .add(axis.scale(v))
1742        };
1743
1744        let mut checked = 0;
1745        let mut worst: f64 = 0.0;
1746        for (id, text) in &bodies {
1747            if !text.starts_with("SURFACE_CURVE(") && !text.starts_with("SEAM_CURVE(") {
1748                continue;
1749            }
1750            let bundle = refs(*id);
1751            // The emitted 3D curve: LINE(pnt, VECTOR(dir, magnitude)) with
1752            // C(s) = pnt + s·magnitude·dir over [0,1], or CIRCLE(placement, r)
1753            // with C(a) = c + r(cos a·x̂ + sin a·ŷ) over [0, 2π].
1754            let curve_id = bundle[0];
1755            let curve_body = body(curve_id);
1756            let (domain, curve_3d): (f64, Box<dyn Fn(f64) -> Vec3>) =
1757                if curve_body.starts_with("LINE(") {
1758                    let parts = refs(curve_id);
1759                    let start = vector3(parts[0]);
1760                    let vector = refs(parts[1]);
1761                    let magnitude = numbers(parts[1])[0];
1762                    let direction = vector3(vector[0]);
1763                    (1.0, Box::new(move |s| start.add(direction.scale(magnitude * s))))
1764                } else {
1765                    let arc = refs(refs(curve_id)[0]);
1766                    let radius = numbers(curve_id)[0];
1767                    let center = vector3(arc[0]);
1768                    let arc_axis = vector3(arc[1]);
1769                    let arc_x = vector3(arc[2]);
1770                    let arc_y = arc_axis.cross(arc_x);
1771                    (
1772                        std::f64::consts::TAU,
1773                        Box::new(move |a: f64| {
1774                            center
1775                                .add(arc_x.scale(radius * a.cos()))
1776                                .add(arc_y.scale(radius * a.sin()))
1777                        }),
1778                    )
1779                };
1780            for pcurve_id in &bundle[1..] {
1781                let pcurve = refs(*pcurve_id);
1782                if pcurve[0] != surface_id {
1783                    continue; // the cap-plane half; this test grades the cylinder
1784                }
1785                let geometry = refs(refs(*pcurve_id)[1])[0];
1786                assert!(body(geometry).starts_with("LINE("), "iso-lines only");
1787                let parts = refs(geometry);
1788                let point = numbers(parts[0]);
1789                let magnitude = numbers(parts[1])[0];
1790                let direction = numbers(refs(parts[1])[0]);
1791                for step_index in 0..=40 {
1792                    let s = domain * step_index as f64 / 40.0;
1793                    let u = point[0] + s * magnitude * direction[0];
1794                    let v = point[1] + s * magnitude * direction[1];
1795                    worst = worst.max(evaluate(u, v).sub(curve_3d(s)).length());
1796                }
1797                checked += 1;
1798            }
1799        }
1800        assert_eq!(checked, 4, "two rim circles and both halves of the seam");
1801        // Observed 8.882e-16 mm on a radius-2, height-5 cylinder. The plan's
1802        // bar was 1e-9·scale = 5e-9; this asserts 1e-12, three orders tighter
1803        // and still ~1000x the observation.
1804        assert!(
1805            worst < 1e-12,
1806            "independent re-evaluation deviated {worst:.3e} mm"
1807        );
1808    }
1809
1810    /// A pointed cone's wall is a full-revolution B-spline patch whose two
1811    /// generatrix coedges are the SAME edge, so it exercises the seam path:
1812    /// both uses land on one surface and the edge goes out as a SEAM_CURVE
1813    /// carrying both pcurves, forward slot first.
1814    ///
1815    /// The rim on that B-spline wall is the case a REMAP cannot serve and a
1816    /// RESAMPLE can: the stored pcurve is synchronised to the kernel's
1817    /// rational-arc parameter and the emitted circle to the angle, so the
1818    /// stored net may not be reused, but reading it at
1819    /// `circle_angle_to_parameter(angle)` — the exact inverse of the `make_arc`
1820    /// construction the recognizer matched — gives the right point for every
1821    /// emitted parameter, and the fit through those points carries it.
1822    #[test]
1823    fn pointed_cone_seam_exports_seam_curve_with_both_pcurves() {
1824        let solid =
1825            make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 0.0, 5.0).unwrap();
1826        let report =
1827            export_step_report(std::slice::from_ref(&solid), "cone", "millimeter", "fixed")
1828                .unwrap();
1829        assert_eq!(report.seam_curves, 1);
1830        assert_eq!(report.surface_curves, 1);
1831        // Both uses of the seam and both uses of the rim: full coverage.
1832        assert_eq!(report.pcurves_written, 4);
1833        assert_eq!(report.pcurves_omitted, 0);
1834        assert_eq!(report.bare_curves, 0);
1835        assert_eq!(report.text.matches("SEAM_CURVE(").count(), 1);
1836        assert!(audit_step_pcurves(&report.text).is_empty());
1837        // The two seam pcurves are exact (8.6e-16 mm); the fitted rim on the
1838        // B-spline wall dominates at 3.659e-5 mm after refinement — it started
1839        // at 2.393e-3 with a single 33-sample fit, which is inside the 4e-3
1840        // band but only by 1.7x, so the refinement loop earns its keep here.
1841        // Band 1e-4 is 2.7x the observation; the shipping gate is 40x looser.
1842        assert!(
1843            report.max_pcurve_deviation < 1e-4,
1844            "pcurve deviation {:.3e} exceeded 1e-4",
1845            report.max_pcurve_deviation
1846        );
1847        let imported = import_step(&report.text).expect("import");
1848        let volume = solid_mass_properties(&imported[0]).unwrap().volume;
1849        let expected = solid_mass_properties(&solid).unwrap().volume;
1850        assert!(((volume - expected) / expected).abs() < 1e-6);
1851    }
1852
1853    /// Boolean and fillet solids: the edges that are neither iso-lines nor rims.
1854    ///
1855    /// `box_minus_cylinder` reaches full coverage only through the fit — one of
1856    /// its two hole rims runs CLOCKWISE in its cap plane's parameters, and a 2D
1857    /// `AXIS2_PLACEMENT_2D` is right-handed by definition, so the exact 2D
1858    /// `CIRCLE` for that one is the MIRROR of the truth and misses by the full
1859    /// diameter (measured: 3.000e0 mm, refused). The fit carries it instead.
1860    /// The filleted box has no analytic answer for any of its blend edges.
1861    ///
1862    /// "0 omitted" is a coverage claim, not a correctness one — correctness is
1863    /// the writer's gate, which measured every one of these against the emitted
1864    /// entities before writing it, `audit_step_pcurves` on the serialized text,
1865    /// and (for the analytic half) the independent re-evaluation in
1866    /// `pcurve_parameter_shared_with_analytic_curve`. A fitted 2D B-spline has
1867    /// no independent evaluator here; `step-validation/export-oracle.mjs` is
1868    /// where OpenCASCADE grades those.
1869    #[test]
1870    fn boolean_and_fillet_solids_reach_full_pcurve_coverage() {
1871        let axis = Vec3::new(0.0, 0.0, 1.0);
1872        let block = make_box_brep(Vec3::new(-3.0, -3.0, 0.0), 6.0, 6.0, 4.0).unwrap();
1873        let drill = make_cylinder_brep(Vec3::new(0.0, 0.0, -1.0), axis, 1.5, 6.0).unwrap();
1874        let cut = boolean_operation(
1875            &block,
1876            &drill,
1877            BooleanOperation::Subtract,
1878            &BooleanOptions::default(),
1879        )
1880        .unwrap();
1881        let report =
1882            export_step_report(std::slice::from_ref(&cut), "cut", "millimeter", "fixed").unwrap();
1883        assert_eq!(report.pcurves_omitted, 0, "boolean: omitted");
1884        assert_eq!(report.bare_curves, 0, "boolean: bare");
1885        assert_eq!(report.pcurves_written, 30, "boolean: written");
1886        assert_eq!(report.seam_curves, 1, "boolean: the drill's seam");
1887        assert!(audit_step_pcurves(&report.text).is_empty());
1888        // Observed 3.885e-6 mm, dominated by the fitted clockwise rim (a single
1889        // 33-sample fit gave 6.062e-5 before refinement). Band 1e-5 is 2.6x the
1890        // observation; the shipping gate is 4e-3, 1000x looser.
1891        assert!(
1892            report.max_pcurve_deviation < 1e-5,
1893            "boolean pcurve deviation {:.3e} exceeded 1e-5",
1894            report.max_pcurve_deviation
1895        );
1896
1897        let plain = make_box_brep(Vec3::default(), 6.0, 6.0, 4.0).unwrap();
1898        let rounded = crate::fillet_edges(&plain, &[Vec3::new(0.0, 0.0, 2.0)], None, 0.8, false, None)
1899            .expect("fillet");
1900        let report =
1901            export_step_report(std::slice::from_ref(&rounded), "fillet", "millimeter", "fixed")
1902                .unwrap();
1903        assert_eq!(report.pcurves_omitted, 0, "fillet: omitted");
1904        assert_eq!(report.pcurves_written, 30, "fillet: written");
1905        assert!(audit_step_pcurves(&report.text).is_empty());
1906        // Observed 1.264e-7 mm; band 1e-6 is 8x that.
1907        assert!(
1908            report.max_pcurve_deviation < 1e-6,
1909            "fillet pcurve deviation {:.3e} exceeded 1e-6",
1910            report.max_pcurve_deviation
1911        );
1912        assert!(import_step(&report.text).is_ok());
1913    }
1914
1915    /// A boundary collapsed to a single point survives a re-export.
1916    ///
1917    /// `abc_00000036` is a vendor file that uses `VERTEX_LOOP`, and our
1918    /// importer turns one into a face bound holding a single degenerate edge
1919    /// (`builder/collect.rs`). Its two solids come back carrying two such
1920    /// bounds between them. Before this the exporter dropped ANY bound whose
1921    /// coedges were all degenerate, so those collapsed boundaries vanished
1922    /// from their faces' parameter domains and the next reader had to
1923    /// re-synthesise them from the surfaces' own degeneracy. They now go back
1924    /// out as the `VERTEX_LOOP`s they came in as.
1925    ///
1926    /// (The plan proposed a pointed-cone fixture for this. There isn't one:
1927    /// OpenCASCADE writes a pointed cone with the apex as an ordinary vertex
1928    /// where the seam meets itself, no vertex loop, and our own
1929    /// `make_cone_brep` puts the apex edge inside a MIXED loop, which keeps the
1930    /// skip by design. An imported vendor file is the only place the case
1931    /// arises, so that is what this tests.)
1932    #[test]
1933    fn vertex_loops_survive_a_re_export() {
1934        let text = include_str!(concat!(
1935            env!("CARGO_MANIFEST_DIR"),
1936            "/tests/fixtures/step-import/abc_00000036.step"
1937        ));
1938        let solids = import_step(text).expect("import");
1939        assert_eq!(solids.len(), 2);
1940        let report =
1941            export_step_report(&solids, "abc_00000036", "millimeter", "fixed").expect("export");
1942        // Both collapsed bounds are written, and the entity count in the file
1943        // matches the counter rather than being asserted independently of it.
1944        assert_eq!(report.vertex_loops, 2);
1945        assert_eq!(report.text.matches("VERTEX_LOOP(").count(), 2);
1946        assert!(audit_step_manifold(&report.text).is_empty());
1947        assert!(audit_step_pcurves(&report.text).is_empty());
1948        let reimported = import_step(&report.text).expect("re-import");
1949        assert_eq!(reimported.len(), 2);
1950        for (index, (before, after)) in solids.iter().zip(&reimported).enumerate() {
1951            assert!(
1952                after.validate().is_empty(),
1953                "solid {index} invalid after re-import: {:?}",
1954                after.validate()
1955            );
1956            let original = solid_mass_properties(before).unwrap().volume;
1957            let volume = solid_mass_properties(after).unwrap().volume;
1958            let relative = ((volume - original) / original).abs();
1959            assert!(
1960                relative < 1e-6,
1961                "solid {index} volume {volume} vs {original} (rel {relative:.3e})"
1962            );
1963        }
1964    }
1965
1966    /// The pcurve audit reads the serialized graph, so it has to catch bundles
1967    /// this writer would never produce as well as the ones it does.
1968    #[test]
1969    fn step_pcurve_audit_rejects_malformed_bundles() {
1970        // The audit's own contract: one entity per line, unindented, exactly
1971        // as `StepWriter` emits them.
1972        let bundle = |surface_curve: &str, pcurve: &str, representation: &str| {
1973            [
1974                "#1=PLANE('',#9);",
1975                representation,
1976                pcurve,
1977                surface_curve,
1978                "#5=EDGE_CURVE('',#10,#11,#4,.T.);",
1979            ]
1980            .join("\n")
1981        };
1982        let representation = "#2=DEFINITIONAL_REPRESENTATION('',(#8),#7);";
1983        let pcurve = "#3=PCURVE('',#1,#2);";
1984        let good = bundle(
1985            "#4=SURFACE_CURVE('',#6,(#3),.CURVE_3D.);",
1986            pcurve,
1987            representation,
1988        );
1989        assert!(audit_step_pcurves(&good).is_empty());
1990        // A SEAM_CURVE must carry exactly two pcurves.
1991        let short_seam = bundle(
1992            "#4=SEAM_CURVE('',#6,(#3),.CURVE_3D.);",
1993            pcurve,
1994            representation,
1995        );
1996        assert_eq!(audit_step_pcurves(&short_seam).len(), 1);
1997        // Two pcurves naming ONE surface is a seam, and must say so.
1998        let mislabelled = bundle(
1999            "#4=SURFACE_CURVE('',#6,(#3,#3),.CURVE_3D.);",
2000            pcurve,
2001            representation,
2002        );
2003        assert_eq!(audit_step_pcurves(&mislabelled).len(), 1);
2004        // A PCURVE with no DEFINITIONAL_REPRESENTATION carries no geometry.
2005        let no_representation = bundle(
2006            "#4=SURFACE_CURVE('',#6,(#3),.CURVE_3D.);",
2007            pcurve,
2008            "#2=REPRESENTATION('',(#8),#7);",
2009        );
2010        assert_eq!(audit_step_pcurves(&no_representation).len(), 1);
2011        // An EDGE_CURVE that kept a bare 3D curve is the counted, deliberate
2012        // outcome of the omission gate — never an audit issue.
2013        let bare = "#1=LINE('',#2,#3);\n#5=EDGE_CURVE('',#10,#11,#1,.T.);";
2014        assert!(audit_step_pcurves(bare).is_empty());
2015    }
2016
2017
2018    #[test]
2019    fn cylinder_round_trips_through_analytic_entities() {
2020        let solid = make_cylinder_brep(
2021            Vec3::new(1.0, -2.0, 0.5),
2022            Vec3::new(0.0, 0.0, 1.0),
2023            2.0,
2024            5.0,
2025        )
2026        .unwrap();
2027        assert_analytic_round_trip(
2028            "cylinder",
2029            &solid,
2030            &[
2031                "CYLINDRICAL_SURFACE(",
2032                "PLANE(",
2033                "CIRCLE(",
2034                "LINE(",
2035                "VECTOR(",
2036            ],
2037            true,
2038        );
2039    }
2040
2041    #[test]
2042    fn cylinder_export_keeps_unit_conversion_entities() {
2043        let cylinder =
2044            make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
2045        let step = export_step(&[cylinder], "cylinder", "inch", "fixed").unwrap();
2046        assert!(step.contains("CYLINDRICAL_SURFACE("));
2047        assert!(step.contains("CONVERSION_BASED_UNIT('INCH'"));
2048    }
2049
2050    #[test]
2051    fn frustum_round_trips_through_conical_surface() {
2052        let solid = make_cone_brep(
2053            Vec3::new(0.5, 0.5, -1.0),
2054            Vec3::new(0.0, 0.0, 1.0),
2055            3.0,
2056            1.5,
2057            5.0,
2058        )
2059        .unwrap();
2060        assert_analytic_round_trip("frustum", &solid, &["CONICAL_SURFACE(", "PLANE("], true);
2061    }
2062
2063    #[test]
2064    fn pointed_cone_wall_stays_nurbs_but_caps_and_rim_export_analytic() {
2065        // The importer's axial cover margin clamps a negative apex-end radius
2066        // to zero, bending the rebuilt slope; an apex-touching CONICAL export
2067        // would round-trip with ~1e-4 relative volume error, so the wall must
2068        // honestly stay NURBS while the cap plane and rim circle go analytic.
2069        let solid =
2070            make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 0.0, 5.0).unwrap();
2071        let step =
2072            export_step(std::slice::from_ref(&solid), "cone", "millimeter", "fixed").unwrap();
2073        assert!(!step.contains("CONICAL_SURFACE("));
2074        assert!(step.contains("B_SPLINE_SURFACE"));
2075        assert!(step.contains("PLANE("));
2076        assert!(step.contains("CIRCLE("));
2077        let imported = import_step(&step).expect("import");
2078        let volume = solid_mass_properties(&imported[0]).unwrap().volume;
2079        let expected = solid_mass_properties(&solid).unwrap().volume;
2080        assert!(((volume - expected) / expected).abs() < 1e-6);
2081    }
2082
2083    #[test]
2084    fn sphere_round_trips_through_spherical_surface() {
2085        let solid =
2086            make_sphere_brep(Vec3::new(2.0, 1.0, -1.0), 3.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
2087        assert_analytic_round_trip("sphere", &solid, &["SPHERICAL_SURFACE(", "CIRCLE("], true);
2088    }
2089
2090    #[test]
2091    fn torus_round_trips_through_toroidal_surface() {
2092        let solid =
2093            make_torus_brep(Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, 1.0), 5.0, 1.5).unwrap();
2094        assert_analytic_round_trip("torus", &solid, &["TOROIDAL_SURFACE(", "CIRCLE("], true);
2095    }
2096
2097    #[test]
2098    fn box_minus_cylinder_round_trips_with_analytic_entities() {
2099        let block = make_box_brep(Vec3::new(-3.0, -3.0, 0.0), 6.0, 6.0, 4.0).unwrap();
2100        let drill = make_cylinder_brep(
2101            Vec3::new(0.0, 0.0, -1.0),
2102            Vec3::new(0.0, 0.0, 1.0),
2103            1.5,
2104            6.0,
2105        )
2106        .unwrap();
2107        let cut = boolean_operation(
2108            &block,
2109            &drill,
2110            BooleanOperation::Subtract,
2111            &BooleanOptions::default(),
2112        )
2113        .unwrap();
2114        // Boolean-produced edges may be split subranges (NURBS fallback), so
2115        // only the surface entities are required to be analytic here.
2116        let solid = assert_analytic_round_trip(
2117            "box_minus_cyl",
2118            &cut,
2119            &["CYLINDRICAL_SURFACE(", "PLANE("],
2120            false,
2121        );
2122        assert_eq!(solid.genus, 1, "through-hole genus survives the round trip");
2123    }
2124}