Skip to main content

step_io/build/
mod.rs

1//! [`StepBuilder`] — the convenience authoring layer over the strict
2//! [`Ap242Author`] constructors.
3//!
4//! [`new`](StepBuilder::new) lays down the skeleton every exporter needs
5//! (application protocol, product contexts, units —
6//! [`new_with`](StepBuilder::new_with) to choose them);
7//! [`finish`](StepBuilder::finish) assembles everything added so far and
8//! returns the Part 21 text.
9//!
10//! The core is parts and their shape. [`part`](StepBuilder::part) adds one
11//! product with its full definition chain
12//! ([`part_with`](StepBuilder::part_with) also sets part number / version /
13//! descriptions). Topology mirrors the kernel's own —
14//! [`vertex`](StepBuilder::vertex) → [`edge`](StepBuilder::edge) →
15//! [`face`](StepBuilder::face) → [`solid`](StepBuilder::solid), with analytic
16//! surfaces and curves as plain inputs ([`SurfaceInput`], [`CurveInput`],
17//! [`ProfileInput`]). Assemblies are one
18//! [`place(parent, child, at)`](StepBuilder::place) call per placed
19//! instance, the child's definition shared between them.
20//!
21//! Everything else is one call each. [`mesh`](StepBuilder::mesh) attaches a
22//! display mesh (vertex array + index triangles), optionally linked to the
23//! b-rep solid it renders. [`style`](StepBuilder::style) colours a solid or
24//! a single face, with optional transparency; [`layer`](StepBuilder::layer),
25//! [`hide`](StepBuilder::hide), and [`hide_layer`](StepBuilder::hide_layer)
26//! manage presentation layers and default visibility.
27//! [`contributor`](StepBuilder::contributor),
28//! [`approve`](StepBuilder::approve), [`document`](StepBuilder::document),
29//! and [`security`](StepBuilder::security) attach PLM records to a part's
30//! version, with people as shared
31//! [`person_and_org`](StepBuilder::person_and_org) handles.
32//! [`header`](StepBuilder::header) fills the Part 21 file header.
33//!
34//! Anything the builder does not cover is still reachable:
35//! [`author`](StepBuilder::author) hands out the underlying [`Ap242Author`],
36//! and ids cross freely between the two levels.
37//!
38//! The minimal flow — a planar part handed over from a kernel:
39//!
40//! ```no_run
41//! use step_io::build::{CurveInput, FaceBoundInput, Frame, SurfaceInput};
42//!
43//! # struct KernelEdge { start: usize, end: usize }
44//! # struct KernelFace { origin: [f64; 3], normal: [f64; 3], x_dir: [f64; 3], bound: Vec<usize> }
45//! # struct Kernel { points: Vec<[f64; 3]>, edges: Vec<KernelEdge>, faces: Vec<KernelFace> }
46//! # let kernel = Kernel { points: vec![], edges: vec![], faces: vec![] };
47//! let mut b = step_io::StepBuilder::new().unwrap();
48//! let plate = b.part("plate").unwrap();
49//!
50//! let vs: Vec<_> = kernel.points.iter()
51//!     .map(|p| b.vertex(*p).unwrap())
52//!     .collect();
53//! let es: Vec<_> = kernel.edges.iter()
54//!     .map(|e| b.edge(vs[e.start], vs[e.end], CurveInput::Line).unwrap())
55//!     .collect();
56//! let fs: Vec<_> = kernel.faces.iter()
57//!     .map(|f| {
58//!         let plane = Frame { origin: f.origin, axis: f.normal, ref_dir: f.x_dir };
59//!         let bound = f.bound.iter().map(|&i| (es[i], true)).collect();
60//!         b.face(SurfaceInput::Plane(plane), true, vec![FaceBoundInput::outer(bound)])
61//!             .unwrap()
62//!     })
63//!     .collect();
64//! b.solid(plate, "plate body", fs).unwrap();
65//!
66//! std::fs::write("plate.step", b.finish().unwrap()).unwrap();
67//! ```
68//!
69//! The full tour — assembly, display mesh, presentation, metadata, header:
70//!
71//! ```no_run
72//! use step_io::build::{
73//!     ApprovalInput, Frame, HeaderInput, MeshInput, MeshNormalsInput, PersonOrg,
74//!     Rgb, StyleTarget,
75//! };
76//!
77//! # struct Kernel { mesh_points: Vec<[f64; 3]>, mesh_triangles: Vec<[usize; 3]> }
78//! # let kernel = Kernel { mesh_points: vec![], mesh_triangles: vec![] };
79//! # fn faces(_b: &mut step_io::StepBuilder) -> Vec<step_io::generated::model::AdvancedFaceId> { vec![] }
80//! let mut b = step_io::StepBuilder::new().unwrap();
81//! b.header(&HeaderInput {
82//!     file_name: Some("car.step".into()),
83//!     organizations: vec!["ACME".into()],
84//!     originating_system: Some("MyKernel 2.1".into()),
85//!     ..Default::default() // timestamp auto-stamped
86//! });
87//!
88//! let car = b.part("car").unwrap();
89//! let wheel = b.part("wheel").unwrap();
90//! let wheel_faces = faces(&mut b); // b-rep translated as in the minimal flow
91//! let body = b.solid(wheel, "wheel body", wheel_faces).unwrap();
92//!
93//! // One placed instance per call; the wheel's definition is shared.
94//! let z = [0.0, 0.0, 1.0];
95//! let x = [1.0, 0.0, 0.0];
96//! b.place(car, wheel, Frame { origin: [900.0, 700.0, 300.0], axis: z, ref_dir: x }).unwrap();
97//! b.place(car, wheel, Frame { origin: [900.0, -700.0, 300.0], axis: z, ref_dir: x }).unwrap();
98//!
99//! // Display mesh (the kernel's tessellation) linked to the b-rep it renders.
100//! b.mesh(wheel, "wheel mesh", &MeshInput {
101//!     points: kernel.mesh_points,
102//!     triangles: kernel.mesh_triangles,
103//!     normals: MeshNormalsInput::None,
104//! }, Some(body.into())).unwrap();
105//!
106//! // Presentation: colour/transparency, layers, default visibility.
107//! b.style(StyleTarget::Solid(body), Rgb { red: 0.8, green: 0.2, blue: 0.1 }, None).unwrap();
108//! let hidden = b.layer("REFERENCE", vec![StyleTarget::Solid(body)]).unwrap();
109//! b.hide_layer(hidden);
110//!
111//! // PLM metadata on the part's version.
112//! let john = b.person_and_org(&PersonOrg {
113//!     person_id: "p1".into(),
114//!     first_name: Some("John".into()),
115//!     last_name: Some("Doe".into()),
116//!     organization: "ACME".into(),
117//! }).unwrap();
118//! b.contributor(wheel, "creator", john).unwrap();
119//! b.approve(wheel, &ApprovalInput {
120//!     status: "approved".into(),
121//!     level: "final".into(),
122//!     approvers: vec![(john, "approver".into())],
123//!     date: None,
124//! }).unwrap();
125//!
126//! let text = b.finish().unwrap();
127//! # let _ = text;
128//! ```
129
130// Shared with the read side (`scene`): the NURBS forms `to_nurbs()` returns are
131// exactly what the builder accepts, and colours round-trip as the same type.
132use crate::generated::author::{Ap242Author, AuthorError};
133use crate::generated::model as m;
134use crate::header::FileHeader;
135pub use crate::scene::{NurbsCurve, NurbsSurface, Rgb};
136
137/// Part 21 HEADER fields for [`StepBuilder::header`]. Everything defaults
138/// to empty except the pieces the builder supplies itself: the timestamp
139/// (current UTC time unless overridden) and the preprocessor stamp
140/// (always `step-io <version>`).
141#[derive(Debug, Clone, Default)]
142pub struct HeaderInput {
143    /// `FILE_NAME.name` — the customary file name.
144    pub file_name: Option<String>,
145    /// `FILE_DESCRIPTION` entry.
146    pub description: Option<String>,
147    pub authors: Vec<String>,
148    pub organizations: Vec<String>,
149    /// `FILE_NAME.originating_system` — the exporting kernel/application.
150    pub originating_system: Option<String>,
151    pub authorisation: Option<String>,
152    /// `None` = stamp the current UTC time; `Some` = used verbatim (fix it
153    /// for reproducible output, or `""` to leave the field blank).
154    pub timestamp: Option<String>,
155}
156
157/// Days-from-civil inverse (Howard Hinnant's algorithm): unix seconds to a
158/// `YYYY-MM-DDThh:mm:ssZ` UTC stamp.
159fn iso_utc_from_unix(secs: u64) -> String {
160    let days = secs / 86_400;
161    let rem = secs % 86_400;
162    let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
163    let z = days + 719_468;
164    let era = z / 146_097;
165    let doe = z % 146_097;
166    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
167    let year = yoe + era * 400;
168    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
169    let mp = (5 * doy + 2) / 153;
170    let day = doy - (153 * mp + 2) / 5 + 1;
171    let month = if mp < 10 { mp + 3 } else { mp - 9 };
172    let year = if month <= 2 { year + 1 } else { year };
173    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
174}
175
176/// The current time as an ISO 8601 UTC stamp.
177fn now_iso_utc() -> String {
178    let secs = std::time::SystemTime::now()
179        .duration_since(std::time::UNIX_EPOCH)
180        .map_or(0, |d| d.as_secs());
181    iso_utc_from_unix(secs)
182}
183
184/// The file's length unit, chosen at [`StepBuilder::new_with`] time (a
185/// unit context is a file-global entity every shape references).
186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
187pub enum LengthUnit {
188    #[default]
189    Millimetre,
190    Metre,
191}
192
193/// Unit setup for [`StepBuilder::new_with`]; the default is what plain
194/// [`new`](StepBuilder::new) uses (millimetre, 1e-7 uncertainty).
195#[derive(Debug, Clone, Copy)]
196pub struct UnitsInput {
197    pub length: LengthUnit,
198    /// Length uncertainty (modelling precision), in the file's own length
199    /// unit — expected finite and positive (degenerate values are the
200    /// caller's responsibility).
201    pub uncertainty: f64,
202}
203
204impl Default for UnitsInput {
205    fn default() -> Self {
206        Self {
207            length: LengthUnit::Millimetre,
208            uncertainty: 1.0e-7,
209        }
210    }
211}
212
213/// A right-handed placement frame: `origin` plus the `axis` (local Z) and
214/// `ref_dir` (local X) directions — the builder's input for
215/// `AXIS2_PLACEMENT_3D`. Directions are passed through as given (kernels
216/// supply unit vectors); geometric well-formedness is the caller's
217/// responsibility.
218#[derive(Debug, Clone, Copy)]
219pub struct Frame {
220    pub origin: [f64; 3],
221    pub axis: [f64; 3],
222    pub ref_dir: [f64; 3],
223}
224
225/// Surface input for [`StepBuilder::face`]: the stored analytic forms, plus
226/// NURBS for everything else — [`NurbsSurface`] is the same type
227/// `Surface::to_nurbs()` returns on the read side, so read-side output can
228/// be fed straight back in. Non-rational data (all weights `1.0`) becomes a
229/// plain `B_SPLINE_SURFACE_WITH_KNOTS`; rational data becomes the customary
230/// multi-part complex instance.
231#[derive(Debug, Clone)]
232pub enum SurfaceInput {
233    Plane(Frame),
234    /// Cylinder around the frame's axis with the given radius.
235    Cylinder(Frame, f64),
236    /// Sphere centred at the frame's origin with the given radius.
237    Sphere(Frame, f64),
238    /// Torus around the frame's axis: major radius, then minor radius.
239    Torus(Frame, f64, f64),
240    /// Cone around the frame's axis: radius at the frame's origin, then the
241    /// half-angle in radians.
242    Cone(Frame, f64, f64),
243    /// Profile swept along an extrusion vector (direction × length).
244    LinearExtrusion(ProfileInput, [f64; 3]),
245    /// Profile revolved around an axis: axis origin, then axis direction.
246    Revolution(ProfileInput, [f64; 3], [f64; 3]),
247    Nurbs(NurbsSurface),
248}
249
250/// Curve input for [`StepBuilder::edge`]. [`NurbsCurve`] mirrors the
251/// read-side `to_nurbs()` type; see [`SurfaceInput`] for the
252/// rational/non-rational split.
253#[derive(Debug, Clone)]
254pub enum CurveInput {
255    /// Straight segment — computed from the two vertex positions.
256    Line,
257    /// Circle in the frame's XY plane with the given radius; with equal
258    /// end vertices this is a full circle, otherwise an arc.
259    Circle(Frame, f64),
260    /// Ellipse in the frame's XY plane: first and second semi-axis; with
261    /// equal end vertices a full ellipse, otherwise an arc.
262    Ellipse(Frame, f64, f64),
263    /// Piecewise-linear curve through the given points (customarily the
264    /// first/last coincide with the edge's vertices).
265    Polyline(Vec<[f64; 3]>),
266    Nurbs(NurbsCurve),
267}
268
269/// A profile curve for the swept surfaces — plain geometry, no vertices
270/// (unlike [`CurveInput::Line`], which derives from edge vertices).
271#[derive(Debug, Clone)]
272pub enum ProfileInput {
273    /// Infinite straight line: a point and a direction.
274    Line([f64; 3], [f64; 3]),
275    Circle(Frame, f64),
276    Ellipse(Frame, f64, f64),
277    Nurbs(NurbsCurve),
278}
279
280/// One face bound for [`StepBuilder::face`]: the loop's edges with their
281/// per-edge direction, whether this is the outer bound, and the bound's own
282/// orientation flag.
283#[derive(Debug, Clone)]
284pub struct FaceBoundInput {
285    pub edges: Vec<(m::EdgeCurveId, bool)>,
286    pub outer: bool,
287    pub orientation: bool,
288}
289
290impl FaceBoundInput {
291    /// The customary outer bound (orientation `true`).
292    #[must_use]
293    pub fn outer(edges: Vec<(m::EdgeCurveId, bool)>) -> Self {
294        Self {
295            edges,
296            outer: true,
297            orientation: true,
298        }
299    }
300
301    /// An inner (hole) bound (orientation `true`).
302    #[must_use]
303    pub fn inner(edges: Vec<(m::EdgeCurveId, bool)>) -> Self {
304        Self {
305            edges,
306            outer: false,
307            orientation: true,
308        }
309    }
310}
311
312/// Compress an expanded knot vector (each knot repeated by multiplicity —
313/// the [`NurbsCurve`]/[`NurbsSurface`] form) into STEP's distinct-knots +
314/// multiplicities pair. Exact equality groups repeats; the read side expands
315/// them back identically, so the round trip is lossless.
316fn compress_knots(expanded: &[f64]) -> (Vec<f64>, Vec<i64>) {
317    let mut knots: Vec<f64> = Vec::new();
318    let mut multiplicities: Vec<i64> = Vec::new();
319    for &k in expanded {
320        if knots.last() == Some(&k) {
321            *multiplicities.last_mut().unwrap() += 1;
322        } else {
323            knots.push(k);
324            multiplicities.push(1);
325        }
326    }
327    (knots, multiplicities)
328}
329
330/// Optional overrides for [`StepBuilder::part_with`] — every field defaults
331/// to the plain [`part`](StepBuilder::part) behaviour.
332#[derive(Debug, Clone, Default)]
333pub struct PartOptions {
334    /// `PRODUCT.id` (part number); defaults to the part name.
335    pub id: Option<String>,
336    /// `PRODUCT.description`.
337    pub description: Option<String>,
338    /// Version label (`PRODUCT_DEFINITION_FORMATION.id`); defaults to `""`.
339    pub version: Option<String>,
340    /// `PRODUCT_DEFINITION_FORMATION.description`.
341    pub version_description: Option<String>,
342    /// `PRODUCT_DEFINITION.description`.
343    pub definition_description: Option<String>,
344}
345
346/// A person at an organization, for [`StepBuilder::person_and_org`].
347#[derive(Debug, Clone)]
348pub struct PersonOrg {
349    pub person_id: String,
350    pub first_name: Option<String>,
351    pub last_name: Option<String>,
352    pub organization: String,
353}
354
355/// A calendar date with wall-clock time (no time zone — emitted as UTC).
356#[derive(Debug, Clone, Copy)]
357pub struct DateTimeInput {
358    pub year: i64,
359    pub month: i64,
360    pub day: i64,
361    pub hour: i64,
362    pub minute: Option<i64>,
363}
364
365/// One approval for [`StepBuilder::approve`]: a status word (customarily
366/// `"approved"`), a level, any number of approvers with their roles, and an
367/// optional approval date.
368#[derive(Debug, Clone, Default)]
369pub struct ApprovalInput {
370    pub status: String,
371    pub level: String,
372    pub approvers: Vec<(m::PersonAndOrganizationId, String)>,
373    pub date: Option<DateTimeInput>,
374}
375
376/// An external document reference for [`StepBuilder::document`].
377#[derive(Debug, Clone)]
378pub struct DocumentInput {
379    pub id: String,
380    pub name: String,
381    pub description: Option<String>,
382    /// `DOCUMENT_TYPE.product_data_type`, e.g. `"drawing"`.
383    pub kind: String,
384}
385
386/// Normals for a [`MeshInput`], mirroring what the read side exposes: none,
387/// one for the whole mesh, or one per vertex (row count must match the
388/// point count — a mismatch is the caller's responsibility).
389#[derive(Debug, Clone, Default)]
390pub enum MeshNormalsInput {
391    #[default]
392    None,
393    Uniform([f64; 3]),
394    PerVertex(Vec<[f64; 3]>),
395}
396
397/// A display mesh for [`StepBuilder::mesh`]: a vertex array plus 0-based
398/// index triangles — the plain form kernels and renderers already hold.
399#[derive(Debug, Clone)]
400pub struct MeshInput {
401    pub points: Vec<[f64; 3]>,
402    pub triangles: Vec<[usize; 3]>,
403    pub normals: MeshNormalsInput,
404}
405
406/// What a [`StepBuilder::style`] call colours: a whole solid (with or without
407/// voids) or one face — the targets the read-side `Scene` resolves styles for.
408#[derive(Debug, Clone, Copy)]
409pub enum StyleTarget {
410    Face(m::AdvancedFaceId),
411    Solid(m::ManifoldSolidBrepId),
412    /// A solid with internal voids ([`StepBuilder::solid_with_voids`]).
413    VoidSolid(m::BrepWithVoidsId),
414}
415
416/// Which solid a display mesh tessellates ([`StepBuilder::mesh`]): a plain
417/// [`solid`](StepBuilder::solid) or one with voids
418/// ([`solid_with_voids`](StepBuilder::solid_with_voids)). The id types convert
419/// with [`Into`], so `Some(body.into())` works for either.
420#[derive(Debug, Clone, Copy)]
421pub enum SolidRef {
422    Solid(m::ManifoldSolidBrepId),
423    VoidSolid(m::BrepWithVoidsId),
424}
425
426impl From<m::ManifoldSolidBrepId> for SolidRef {
427    fn from(id: m::ManifoldSolidBrepId) -> Self {
428        SolidRef::Solid(id)
429    }
430}
431
432impl From<m::BrepWithVoidsId> for SolidRef {
433    fn from(id: m::BrepWithVoidsId) -> Self {
434        SolidRef::VoidSolid(id)
435    }
436}
437
438/// Handle for a vertex created by [`StepBuilder::vertex`] — an index into
439/// the builder that created it (a different builder's handle panics or
440/// targets the wrong vertex). The builder keeps the vertex position so
441/// straight edges can derive their line geometry.
442#[derive(Debug, Copy, Clone, PartialEq, Eq)]
443pub struct Vertex(usize);
444
445/// Handle for a part created by [`StepBuilder::part`] — an index into the
446/// builder that created it. Passing it to a different builder panics (out
447/// of range) or silently targets the wrong part (in range).
448#[derive(Debug, Copy, Clone, PartialEq, Eq)]
449pub struct Part(usize);
450
451/// A part's product chain plus the shape items collected for it; the shape
452/// representation itself is materialized in [`StepBuilder::finish`] because
453/// representation items are fixed at insertion.
454struct PendingPart {
455    product: m::ProductId,
456    formation: m::ProductDefinitionFormationId,
457    definition: m::ProductDefinitionId,
458    shape: m::ProductDefinitionShapeId,
459    origin: m::Axis2Placement3dId,
460    items: Vec<m::RepresentationItemRef>,
461    meshes: Vec<m::TessellatedSolidId>,
462}
463
464/// One [`StepBuilder::place`] call: the eagerly-created usage occurrence
465/// plus the wiring deferred to [`StepBuilder::finish`] (the transformation
466/// relationship needs both parts' shape representations, which exist only
467/// then).
468struct PendingPlacement {
469    nauo: m::NextAssemblyUsageOccurrenceId,
470    parent: usize,
471    child: usize,
472    to: m::Axis2Placement3dId,
473}
474
475/// Ergonomic writer for the standard AP242 file shape: shared contexts and
476/// units up front, one product chain per [`part`](Self::part), everything
477/// bound and emitted by [`finish`](Self::finish).
478pub struct StepBuilder {
479    author: Ap242Author,
480    ctx: m::ComplexUnitId,
481    product_context: m::ProductContextId,
482    pd_context: m::ProductDefinitionContextId,
483    parts: Vec<PendingPart>,
484    placements: Vec<PendingPlacement>,
485    vertices: Vec<(m::VertexPointId, [f64; 3])>,
486    styles: Vec<m::StyledItemId>,
487    hidden: Vec<m::InvisibleItemRef>,
488    header: HeaderInput,
489}
490
491impl StepBuilder {
492    /// Set up the shared skeleton with the default units (millimetre,
493    /// 1e-7 uncertainty) — see [`new_with`](Self::new_with) to choose.
494    ///
495    /// # Errors
496    /// Propagates [`AuthorError`] from the strict constructors; the wiring
497    /// here is fixed, so an error indicates a bug in the builder itself.
498    pub fn new() -> Result<Self, AuthorError> {
499        Self::new_with(&UnitsInput::default())
500    }
501
502    /// Set up the shared skeleton: application context + protocol definition
503    /// (the AP242 identity values), product contexts, and the geometric
504    /// context complex carrying the chosen SI length unit, radian,
505    /// steradian, and the given length uncertainty.
506    ///
507    /// # Errors
508    /// Propagates [`AuthorError`] from the strict constructors; the wiring
509    /// here is fixed, so an error indicates a bug in the builder itself.
510    pub fn new_with(units: &UnitsInput) -> Result<Self, AuthorError> {
511        let mut a = Ap242Author::new();
512
513        let ac = a.add_application_context("managed model based 3d engineering".to_owned())?;
514        a.add_application_protocol_definition(
515            "international standard".to_owned(),
516            "ap242_managed_model_based_3d_engineering_mim_lf".to_owned(),
517            2011,
518            m::ApplicationContextRef::ApplicationContext(ac),
519        )?;
520        let product_context = a.add_product_context(
521            String::new(),
522            m::ApplicationContextRef::ApplicationContext(ac),
523            "mechanical".to_owned(),
524        )?;
525        let pd_context = a.add_product_definition_context(
526            "part definition".to_owned(),
527            m::ApplicationContextRef::ApplicationContext(ac),
528            "design".to_owned(),
529        )?;
530
531        let length_prefix = match units.length {
532            LengthUnit::Millimetre => Some(m::SiPrefix::Milli),
533            LengthUnit::Metre => None,
534        };
535        let mm = a.add_complex(vec![
536            m::UnitPart::LengthUnit,
537            m::UnitPart::NamedUnit { dimensions: None },
538            m::UnitPart::SiUnit {
539                prefix: length_prefix,
540                name: m::SiUnitName::Metre,
541            },
542        ])?;
543        let rad = a.add_complex(vec![
544            m::UnitPart::NamedUnit { dimensions: None },
545            m::UnitPart::PlaneAngleUnit,
546            m::UnitPart::SiUnit {
547                prefix: None,
548                name: m::SiUnitName::Radian,
549            },
550        ])?;
551        let sr = a.add_complex(vec![
552            m::UnitPart::NamedUnit { dimensions: None },
553            m::UnitPart::SiUnit {
554                prefix: None,
555                name: m::SiUnitName::Steradian,
556            },
557            m::UnitPart::SolidAngleUnit,
558        ])?;
559        let uncertainty = a.add_uncertainty_measure_with_unit(
560            m::MeasureValue {
561                type_name: Some("LENGTH_MEASURE".to_owned()),
562                value: m::MeasureScalar::Real(units.uncertainty),
563            },
564            m::UnitRef::Complex(mm),
565            "distance_accuracy_value".to_owned(),
566            Some("confusion accuracy".to_owned()),
567        )?;
568        let ctx = a.add_complex(vec![
569            m::UnitPart::GeometricRepresentationContext {
570                coordinate_space_dimension: 3,
571            },
572            m::UnitPart::GlobalUncertaintyAssignedContext {
573                uncertainty: vec![
574                    m::UncertaintyMeasureWithUnitRef::UncertaintyMeasureWithUnit(uncertainty),
575                ],
576            },
577            m::UnitPart::GlobalUnitAssignedContext {
578                units: vec![
579                    m::UnitRef::Complex(mm),
580                    m::UnitRef::Complex(rad),
581                    m::UnitRef::Complex(sr),
582                ],
583            },
584            m::UnitPart::RepresentationContext {
585                context_identifier: String::new(),
586                context_type: "3D".to_owned(),
587            },
588        ])?;
589
590        Ok(Self {
591            author: a,
592            ctx,
593            product_context,
594            pd_context,
595            parts: Vec::new(),
596            placements: Vec::new(),
597            vertices: Vec::new(),
598            styles: Vec::new(),
599            hidden: Vec::new(),
600            header: HeaderInput::default(),
601        })
602    }
603
604    /// Set the Part 21 HEADER fields (file name, description, authors,
605    /// organizations, originating system, authorisation, timestamp) applied
606    /// on [`finish`](Self::finish). May be called any time before `finish`;
607    /// the last call wins. Without it the header carries only the automatic
608    /// timestamp and preprocessor stamp.
609    pub fn header(&mut self, h: &HeaderInput) {
610        self.header = h.clone();
611    }
612
613    /// Escape hatch to the strict low-level layer — the write-side
614    /// counterpart of the read side's `Scene::model()`. Entities the builder
615    /// does not cover go through [`Ap242Author`]'s generated constructors
616    /// directly; ids cross freely in both directions (builder-produced ids
617    /// feed low-level calls and vice versa — same model, same validation).
618    ///
619    /// Only borrowed access is offered: taking the author out would bypass
620    /// [`finish`](Self::finish), which still has to materialize the pending
621    /// shape representations, placements, style anchors, category, and
622    /// header.
623    pub fn author(&mut self) -> &mut Ap242Author {
624        &mut self.author
625    }
626
627    /// Colour `target` (a solid or one face), optionally with a transparency
628    /// (`0.0` = opaque, `1.0` = fully transparent). Styling the same target
629    /// again adds another style record; readers use the first they find.
630    ///
631    /// # Errors
632    /// An id that did not come from this builder (another builder's, or out
633    /// of range) fails validation with [`AuthorError`]; beyond that the
634    /// wiring is fixed, so an error indicates a bug in the builder itself.
635    pub fn style(
636        &mut self,
637        target: StyleTarget,
638        color: Rgb,
639        transparency: Option<f64>,
640    ) -> Result<m::StyledItemId, AuthorError> {
641        let a = &mut self.author;
642        let rgb = a.add_colour_rgb(String::new(), color.red, color.green, color.blue)?;
643        // Colour alone rides the customary fill-area chain; with a
644        // transparency the rendering form carries both.
645        let element = if let Some(t) = transparency {
646            let transparent = a.add_surface_style_transparent(t)?;
647            let rendering = a.add_surface_style_rendering_with_properties(
648                m::ShadingSurfaceMethod::NormalShading,
649                m::ColourRef::ColourRgb(rgb),
650                vec![m::RenderingPropertiesSelectRef::SurfaceStyleTransparent(
651                    transparent,
652                )],
653            )?;
654            m::SurfaceStyleElementSelectRef::SurfaceStyleRenderingWithProperties(rendering)
655        } else {
656            let fill_colour =
657                a.add_fill_area_style_colour(String::new(), m::ColourRef::ColourRgb(rgb))?;
658            let fill_style = a.add_fill_area_style(
659                String::new(),
660                vec![m::FillStyleSelectRef::FillAreaStyleColour(fill_colour)],
661            )?;
662            let fill_area =
663                a.add_surface_style_fill_area(m::FillAreaStyleRef::FillAreaStyle(fill_style))?;
664            m::SurfaceStyleElementSelectRef::SurfaceStyleFillArea(fill_area)
665        };
666        let side_style = a.add_surface_side_style(String::new(), vec![element])?;
667        let usage = a.add_surface_style_usage(
668            m::SurfaceSide::Both,
669            m::SurfaceSideStyleSelectRef::SurfaceSideStyle(side_style),
670        )?;
671        let assignment = a.add_presentation_style_assignment(vec![
672            m::PresentationStyleSelectRef::SurfaceStyleUsage(usage),
673        ])?;
674        let item = match target {
675            StyleTarget::Face(f) => m::StyledItemTargetRef::AdvancedFace(f),
676            StyleTarget::Solid(s) => m::StyledItemTargetRef::ManifoldSolidBrep(s),
677            StyleTarget::VoidSolid(s) => m::StyledItemTargetRef::BrepWithVoids(s),
678        };
679        let styled = a.add_styled_item(
680            String::new(),
681            vec![m::PresentationStyleAssignmentRef::PresentationStyleAssignment(assignment)],
682            item,
683        )?;
684        self.styles.push(styled);
685        Ok(styled)
686    }
687
688    /// Assign `targets` to a named presentation layer; the returned id can
689    /// be fed to [`hide_layer`](Self::hide_layer) to switch the whole layer
690    /// off by default.
691    ///
692    /// # Errors
693    /// An id that did not come from this builder (another builder's, or out
694    /// of range) fails validation with [`AuthorError`]; beyond that the
695    /// wiring is fixed, so an error indicates a bug in the builder itself.
696    pub fn layer(
697        &mut self,
698        name: &str,
699        targets: Vec<StyleTarget>,
700    ) -> Result<m::PresentationLayerAssignmentId, AuthorError> {
701        let items = targets
702            .into_iter()
703            .map(|t| match t {
704                StyleTarget::Face(f) => m::LayeredItemRef::AdvancedFace(f),
705                StyleTarget::Solid(s) => m::LayeredItemRef::ManifoldSolidBrep(s),
706                StyleTarget::VoidSolid(s) => m::LayeredItemRef::BrepWithVoids(s),
707            })
708            .collect();
709        self.author
710            .add_presentation_layer_assignment(name.to_owned(), String::new(), items)
711    }
712
713    /// Mark `target` invisible by default. Implemented the way STEP requires
714    /// it: an `INVISIBILITY` cannot point at geometry directly, so the
715    /// builder attaches an empty (`.NULL.`-styled) styled item to the target
716    /// and registers that — colour styles on the same target are unaffected.
717    ///
718    /// # Errors
719    /// An id that did not come from this builder (another builder's, or out
720    /// of range) fails validation with [`AuthorError`]; beyond that the
721    /// wiring is fixed, so an error indicates a bug in the builder itself.
722    pub fn hide(&mut self, target: StyleTarget) -> Result<(), AuthorError> {
723        let a = &mut self.author;
724        let assignment =
725            a.add_presentation_style_assignment(vec![m::PresentationStyleSelectRef::NullStyle(
726                m::NullStyle::Null,
727            )])?;
728        let item = match target {
729            StyleTarget::Face(f) => m::StyledItemTargetRef::AdvancedFace(f),
730            StyleTarget::Solid(s) => m::StyledItemTargetRef::ManifoldSolidBrep(s),
731            StyleTarget::VoidSolid(s) => m::StyledItemTargetRef::BrepWithVoids(s),
732        };
733        let styled = a.add_styled_item(
734            String::new(),
735            vec![m::PresentationStyleAssignmentRef::PresentationStyleAssignment(assignment)],
736            item,
737        )?;
738        self.styles.push(styled);
739        self.hidden.push(m::InvisibleItemRef::StyledItem(styled));
740        Ok(())
741    }
742
743    /// Switch a whole layer (from [`layer`](Self::layer)) invisible by
744    /// default. Infallible at call time — a foreign or stale id is caught by
745    /// the `INVISIBILITY` validation inside [`finish`](Self::finish).
746    pub fn hide_layer(&mut self, layer: m::PresentationLayerAssignmentId) {
747        self.hidden
748            .push(m::InvisibleItemRef::PresentationLayerAssignment(layer));
749    }
750
751    /// `AXIS2_PLACEMENT_3D` from a [`Frame`].
752    fn placement(&mut self, f: &Frame) -> Result<m::Axis2Placement3dId, AuthorError> {
753        let a = &mut self.author;
754        let location = a.add_cartesian_point(String::new(), f.origin.to_vec())?;
755        let axis = a.add_direction(String::new(), f.axis.to_vec())?;
756        let ref_direction = a.add_direction(String::new(), f.ref_dir.to_vec())?;
757        a.add_axis2_placement3d(
758            String::new(),
759            m::CartesianPointRef::CartesianPoint(location),
760            Some(m::DirectionRef::Direction(axis)),
761            Some(m::DirectionRef::Direction(ref_direction)),
762        )
763    }
764
765    /// One `CARTESIAN_POINT` ref per control point.
766    fn control_points(
767        &mut self,
768        row: &[[f64; 3]],
769    ) -> Result<Vec<m::CartesianPointRef>, AuthorError> {
770        row.iter()
771            .map(|p| {
772                self.author
773                    .add_cartesian_point(String::new(), p.to_vec())
774                    .map(m::CartesianPointRef::CartesianPoint)
775            })
776            .collect()
777    }
778
779    /// Geometry for a swept-surface profile (also backs the ellipse edge).
780    fn profile_geometry(&mut self, profile: &ProfileInput) -> Result<m::CurveRef, AuthorError> {
781        match profile {
782            ProfileInput::Line(point, dir) => {
783                let a = &mut self.author;
784                let pnt = a.add_cartesian_point(String::new(), point.to_vec())?;
785                let orientation = a.add_direction(String::new(), dir.to_vec())?;
786                let vector =
787                    a.add_vector(String::new(), m::DirectionRef::Direction(orientation), 1.0)?;
788                let line = a.add_line(
789                    String::new(),
790                    m::CartesianPointRef::CartesianPoint(pnt),
791                    m::VectorRef::Vector(vector),
792                )?;
793                Ok(m::CurveRef::Line(line))
794            }
795            ProfileInput::Circle(frame, radius) => {
796                let position = self.placement(frame)?;
797                let circle = self.author.add_circle(
798                    String::new(),
799                    m::Axis2PlacementRef::Axis2Placement3d(position),
800                    *radius,
801                )?;
802                Ok(m::CurveRef::Circle(circle))
803            }
804            ProfileInput::Ellipse(frame, semi_1, semi_2) => {
805                let position = self.placement(frame)?;
806                let ellipse = self.author.add_ellipse(
807                    String::new(),
808                    m::Axis2PlacementRef::Axis2Placement3d(position),
809                    *semi_1,
810                    *semi_2,
811                )?;
812                Ok(m::CurveRef::Ellipse(ellipse))
813            }
814            ProfileInput::Nurbs(curve) => self.nurbs_curve_geometry(curve),
815        }
816    }
817
818    /// NURBS curve geometry: non-rational (all weights `1.0`) as a plain
819    /// `B_SPLINE_CURVE_WITH_KNOTS`, rational as the customary multi-part
820    /// complex instance.
821    // Exact comparison intended: only weights that are exactly 1.0 take the
822    // non-rational form; anything else keeps its value in weights_data.
823    #[allow(clippy::float_cmp)]
824    fn nurbs_curve_geometry(&mut self, c: &NurbsCurve) -> Result<m::CurveRef, AuthorError> {
825        let cps = self.control_points(&c.control_points)?;
826        let (knots, knot_multiplicities) = compress_knots(&c.knots);
827        let degree = i64::try_from(c.degree).unwrap_or(i64::MAX);
828        if c.weights.iter().all(|w| *w == 1.0) {
829            let id = self.author.add_b_spline_curve_with_knots(
830                String::new(),
831                degree,
832                cps,
833                m::BSplineCurveForm::Unspecified,
834                m::Logical::Unknown,
835                m::Logical::Unknown,
836                knot_multiplicities,
837                knots,
838                m::KnotType::Unspecified,
839            )?;
840            Ok(m::CurveRef::BSplineCurveWithKnots(id))
841        } else {
842            let id = self.author.add_complex(vec![
843                m::UnitPart::BoundedCurve,
844                m::UnitPart::BSplineCurve {
845                    degree,
846                    control_points_list: cps,
847                    curve_form: m::BSplineCurveForm::Unspecified,
848                    closed_curve: m::Logical::Unknown,
849                    self_intersect: m::Logical::Unknown,
850                },
851                m::UnitPart::BSplineCurveWithKnots {
852                    knot_multiplicities,
853                    knots,
854                    knot_spec: m::KnotType::Unspecified,
855                },
856                m::UnitPart::Curve,
857                m::UnitPart::GeometricRepresentationItem,
858                m::UnitPart::RationalBSplineCurve {
859                    weights_data: c.weights.clone(),
860                },
861                m::UnitPart::RepresentationItem {
862                    name: String::new(),
863                },
864            ])?;
865            Ok(m::CurveRef::Complex(id))
866        }
867    }
868
869    /// NURBS surface geometry; see [`Self::nurbs_curve_geometry`].
870    #[allow(clippy::float_cmp)] // exact 1.0 check, as in nurbs_curve_geometry
871    fn nurbs_surface_geometry(&mut self, s: &NurbsSurface) -> Result<m::SurfaceRef, AuthorError> {
872        let mut cps = Vec::with_capacity(s.control_points.len());
873        for row in &s.control_points {
874            cps.push(self.control_points(row)?);
875        }
876        let (u_knots, u_multiplicities) = compress_knots(&s.knots_u);
877        let (v_knots, v_multiplicities) = compress_knots(&s.knots_v);
878        let u_degree = i64::try_from(s.degree_u).unwrap_or(i64::MAX);
879        let v_degree = i64::try_from(s.degree_v).unwrap_or(i64::MAX);
880        if s.weights.iter().flatten().all(|w| *w == 1.0) {
881            let id = self.author.add_b_spline_surface_with_knots(
882                String::new(),
883                u_degree,
884                v_degree,
885                cps,
886                m::BSplineSurfaceForm::Unspecified,
887                m::Logical::Unknown,
888                m::Logical::Unknown,
889                m::Logical::Unknown,
890                u_multiplicities,
891                v_multiplicities,
892                u_knots,
893                v_knots,
894                m::KnotType::Unspecified,
895            )?;
896            Ok(m::SurfaceRef::BSplineSurfaceWithKnots(id))
897        } else {
898            let id = self.author.add_complex(vec![
899                m::UnitPart::BoundedSurface,
900                m::UnitPart::BSplineSurface {
901                    u_degree,
902                    v_degree,
903                    control_points_list: cps,
904                    surface_form: m::BSplineSurfaceForm::Unspecified,
905                    u_closed: m::Logical::Unknown,
906                    v_closed: m::Logical::Unknown,
907                    self_intersect: m::Logical::Unknown,
908                },
909                m::UnitPart::BSplineSurfaceWithKnots {
910                    u_multiplicities,
911                    v_multiplicities,
912                    u_knots,
913                    v_knots,
914                    knot_spec: m::KnotType::Unspecified,
915                },
916                m::UnitPart::GeometricRepresentationItem,
917                m::UnitPart::RationalBSplineSurface {
918                    weights_data: s.weights.clone(),
919                },
920                m::UnitPart::RepresentationItem {
921                    name: String::new(),
922                },
923                m::UnitPart::Surface,
924            ])?;
925            Ok(m::SurfaceRef::Complex(id))
926        }
927    }
928
929    /// Add a topological vertex at `p`.
930    ///
931    /// # Errors
932    /// Propagates [`AuthorError`] from the strict constructors; the wiring
933    /// here is fixed, so an error indicates a bug in the builder itself.
934    pub fn vertex(&mut self, p: [f64; 3]) -> Result<Vertex, AuthorError> {
935        let point = self.author.add_cartesian_point(String::new(), p.to_vec())?;
936        let vp = self
937            .author
938            .add_vertex_point(String::new(), m::PointRef::CartesianPoint(point))?;
939        self.vertices.push((vp, p));
940        Ok(Vertex(self.vertices.len() - 1))
941    }
942
943    /// Add an edge between two vertices over the given curve. A straight
944    /// edge derives its line from the vertex positions (a zero-length
945    /// segment gets a placeholder direction — degenerate geometry is the
946    /// caller's responsibility); a circle with `from == to` is a closed
947    /// full-circle edge.
948    ///
949    /// # Errors
950    /// Propagates [`AuthorError`] from the strict constructors; the wiring
951    /// here is fixed, so an error indicates a bug in the builder itself.
952    pub fn edge(
953        &mut self,
954        from: Vertex,
955        to: Vertex,
956        curve: CurveInput,
957    ) -> Result<m::EdgeCurveId, AuthorError> {
958        let (start_vertex, start) = self.vertices[from.0];
959        let (end_vertex, end) = self.vertices[to.0];
960        let geometry = match curve {
961            CurveInput::Line => {
962                let delta = [end[0] - start[0], end[1] - start[1], end[2] - start[2]];
963                let len = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
964                let dir = if len < 1e-12 {
965                    [0.0, 0.0, 1.0]
966                } else {
967                    [delta[0] / len, delta[1] / len, delta[2] / len]
968                };
969                let a = &mut self.author;
970                let pnt = a.add_cartesian_point(String::new(), start.to_vec())?;
971                let orientation = a.add_direction(String::new(), dir.to_vec())?;
972                let vector =
973                    a.add_vector(String::new(), m::DirectionRef::Direction(orientation), len)?;
974                let line = a.add_line(
975                    String::new(),
976                    m::CartesianPointRef::CartesianPoint(pnt),
977                    m::VectorRef::Vector(vector),
978                )?;
979                m::CurveRef::Line(line)
980            }
981            CurveInput::Circle(frame, radius) => {
982                let position = self.placement(&frame)?;
983                let circle = self.author.add_circle(
984                    String::new(),
985                    m::Axis2PlacementRef::Axis2Placement3d(position),
986                    radius,
987                )?;
988                m::CurveRef::Circle(circle)
989            }
990            CurveInput::Ellipse(frame, semi_1, semi_2) => {
991                self.profile_geometry(&ProfileInput::Ellipse(frame, semi_1, semi_2))?
992            }
993            CurveInput::Polyline(points) => {
994                let refs = self.control_points(&points)?;
995                m::CurveRef::Polyline(self.author.add_polyline(String::new(), refs)?)
996            }
997            CurveInput::Nurbs(curve) => self.nurbs_curve_geometry(&curve)?,
998        };
999        self.author.add_edge_curve(
1000            String::new(),
1001            m::VertexRef::VertexPoint(start_vertex),
1002            m::VertexRef::VertexPoint(end_vertex),
1003            geometry,
1004            true,
1005        )
1006    }
1007
1008    /// Emit the geometry entity for a [`SurfaceInput`].
1009    fn surface_geometry(&mut self, surface: SurfaceInput) -> Result<m::SurfaceRef, AuthorError> {
1010        Ok(match surface {
1011            SurfaceInput::Plane(frame) => {
1012                let position = self.placement(&frame)?;
1013                let plane = self.author.add_plane(
1014                    String::new(),
1015                    m::Axis2Placement3dRef::Axis2Placement3d(position),
1016                )?;
1017                m::SurfaceRef::Plane(plane)
1018            }
1019            SurfaceInput::Cylinder(frame, radius) => {
1020                let position = self.placement(&frame)?;
1021                let cyl = self.author.add_cylindrical_surface(
1022                    String::new(),
1023                    m::Axis2Placement3dRef::Axis2Placement3d(position),
1024                    radius,
1025                )?;
1026                m::SurfaceRef::CylindricalSurface(cyl)
1027            }
1028            SurfaceInput::Sphere(frame, radius) => {
1029                let position = self.placement(&frame)?;
1030                let sphere = self.author.add_spherical_surface(
1031                    String::new(),
1032                    m::Axis2Placement3dRef::Axis2Placement3d(position),
1033                    radius,
1034                )?;
1035                m::SurfaceRef::SphericalSurface(sphere)
1036            }
1037            SurfaceInput::Torus(frame, major_radius, minor_radius) => {
1038                let position = self.placement(&frame)?;
1039                let torus = self.author.add_toroidal_surface(
1040                    String::new(),
1041                    m::Axis2Placement3dRef::Axis2Placement3d(position),
1042                    major_radius,
1043                    minor_radius,
1044                )?;
1045                m::SurfaceRef::ToroidalSurface(torus)
1046            }
1047            SurfaceInput::Cone(frame, radius, semi_angle) => {
1048                let position = self.placement(&frame)?;
1049                let cone = self.author.add_conical_surface(
1050                    String::new(),
1051                    m::Axis2Placement3dRef::Axis2Placement3d(position),
1052                    radius,
1053                    semi_angle,
1054                )?;
1055                m::SurfaceRef::ConicalSurface(cone)
1056            }
1057            SurfaceInput::LinearExtrusion(profile, sweep) => {
1058                let swept_curve = self.profile_geometry(&profile)?;
1059                let len = (sweep[0] * sweep[0] + sweep[1] * sweep[1] + sweep[2] * sweep[2]).sqrt();
1060                let dir = if len < 1e-12 {
1061                    [0.0, 0.0, 1.0]
1062                } else {
1063                    [sweep[0] / len, sweep[1] / len, sweep[2] / len]
1064                };
1065                let a = &mut self.author;
1066                let orientation = a.add_direction(String::new(), dir.to_vec())?;
1067                let vector =
1068                    a.add_vector(String::new(), m::DirectionRef::Direction(orientation), len)?;
1069                let surf = a.add_surface_of_linear_extrusion(
1070                    String::new(),
1071                    swept_curve,
1072                    m::VectorRef::Vector(vector),
1073                )?;
1074                m::SurfaceRef::SurfaceOfLinearExtrusion(surf)
1075            }
1076            SurfaceInput::Revolution(profile, origin, axis) => {
1077                let swept_curve = self.profile_geometry(&profile)?;
1078                let a = &mut self.author;
1079                let location = a.add_cartesian_point(String::new(), origin.to_vec())?;
1080                let axis_dir = a.add_direction(String::new(), axis.to_vec())?;
1081                let position = a.add_axis1_placement(
1082                    String::new(),
1083                    m::CartesianPointRef::CartesianPoint(location),
1084                    Some(m::DirectionRef::Direction(axis_dir)),
1085                )?;
1086                let surf = a.add_surface_of_revolution(
1087                    String::new(),
1088                    swept_curve,
1089                    m::Axis1PlacementRef::Axis1Placement(position),
1090                )?;
1091                m::SurfaceRef::SurfaceOfRevolution(surf)
1092            }
1093            SurfaceInput::Nurbs(surface) => self.nurbs_surface_geometry(&surface)?,
1094        })
1095    }
1096
1097    /// Add a face over an analytic surface, bounded by the given loops.
1098    ///
1099    /// # Errors
1100    /// An id that did not come from this builder (another builder's, or out
1101    /// of range) fails validation with [`AuthorError`]; beyond that the
1102    /// wiring is fixed, so an error indicates a bug in the builder itself.
1103    pub fn face(
1104        &mut self,
1105        surface: SurfaceInput,
1106        same_sense: bool,
1107        bounds: Vec<FaceBoundInput>,
1108    ) -> Result<m::AdvancedFaceId, AuthorError> {
1109        let face_geometry = self.surface_geometry(surface)?;
1110        let mut face_bounds = Vec::with_capacity(bounds.len());
1111        for bound in bounds {
1112            let mut edge_list = Vec::with_capacity(bound.edges.len());
1113            for (edge, forward) in bound.edges {
1114                let oe = self.author.add_oriented_edge(
1115                    String::new(),
1116                    m::EdgeRef::EdgeCurve(edge),
1117                    forward,
1118                )?;
1119                edge_list.push(m::OrientedEdgeRef::OrientedEdge(oe));
1120            }
1121            let el = self.author.add_edge_loop(String::new(), edge_list)?;
1122            let fb = if bound.outer {
1123                m::FaceBoundRef::FaceOuterBound(self.author.add_face_outer_bound(
1124                    String::new(),
1125                    m::LoopRef::EdgeLoop(el),
1126                    bound.orientation,
1127                )?)
1128            } else {
1129                m::FaceBoundRef::FaceBound(self.author.add_face_bound(
1130                    String::new(),
1131                    m::LoopRef::EdgeLoop(el),
1132                    bound.orientation,
1133                )?)
1134            };
1135            face_bounds.push(fb);
1136        }
1137        self.author
1138            .add_advanced_face(String::new(), face_bounds, face_geometry, same_sense)
1139    }
1140
1141    /// Close the faces into a shell and add the solid to `part`; the solid
1142    /// lands in the part's shape representation on [`finish`](Self::finish)
1143    /// (as an `ADVANCED_BREP_SHAPE_REPRESENTATION`).
1144    ///
1145    /// # Errors
1146    /// An id that did not come from this builder (another builder's, or out
1147    /// of range) fails validation with [`AuthorError`]; beyond that the
1148    /// wiring is fixed, so an error indicates a bug in the builder itself.
1149    pub fn solid(
1150        &mut self,
1151        part: Part,
1152        name: &str,
1153        faces: Vec<m::AdvancedFaceId>,
1154    ) -> Result<m::ManifoldSolidBrepId, AuthorError> {
1155        let shell = self.author.add_closed_shell(
1156            String::new(),
1157            faces.into_iter().map(m::FaceRef::AdvancedFace).collect(),
1158        )?;
1159        let solid = self
1160            .author
1161            .add_manifold_solid_brep(name.to_owned(), m::ClosedShellRef::ClosedShell(shell))?;
1162        self.parts[part.0]
1163            .items
1164            .push(m::RepresentationItemRef::ManifoldSolidBrep(solid));
1165        Ok(solid)
1166    }
1167
1168    /// Close `outer_faces` into the outer shell and each inner face group into
1169    /// a void shell, then add a solid with internal voids (`BREP_WITH_VOIDS`)
1170    /// to `part` — a cavity-bearing body such as a hollow casting. Void shells
1171    /// are wrapped as reversed [`ORIENTED_CLOSED_SHELL`](m::OrientedClosedShell)
1172    /// per the STEP convention. The solid lands in the part's shape
1173    /// representation on [`finish`](Self::finish) (as an
1174    /// `ADVANCED_BREP_SHAPE_REPRESENTATION`, like [`solid`](Self::solid)).
1175    ///
1176    /// `voids` must be non-empty (a solid with no voids is a plain
1177    /// [`solid`](Self::solid)); each inner group is one closed cavity shell.
1178    ///
1179    /// # Errors
1180    /// Empty `voids`, or empty face lists, fail validation with
1181    /// [`AuthorError`] (the strict `CLOSED_SHELL` / `BREP_WITH_VOIDS`
1182    /// cardinality checks). An id that did not come from this builder fails the
1183    /// same way; beyond that the wiring is fixed, so an error indicates a bug
1184    /// in the builder itself.
1185    pub fn solid_with_voids(
1186        &mut self,
1187        part: Part,
1188        name: &str,
1189        outer_faces: Vec<m::AdvancedFaceId>,
1190        voids: Vec<Vec<m::AdvancedFaceId>>,
1191    ) -> Result<m::BrepWithVoidsId, AuthorError> {
1192        let outer = self.author.add_closed_shell(
1193            String::new(),
1194            outer_faces
1195                .into_iter()
1196                .map(m::FaceRef::AdvancedFace)
1197                .collect(),
1198        )?;
1199        let mut void_refs = Vec::with_capacity(voids.len());
1200        for void_faces in voids {
1201            let shell = self.author.add_closed_shell(
1202                String::new(),
1203                void_faces
1204                    .into_iter()
1205                    .map(m::FaceRef::AdvancedFace)
1206                    .collect(),
1207            )?;
1208            // Void shells face inward: the STEP convention is a reversed
1209            // orientation relative to the closed shell's own normals.
1210            let oriented = self.author.add_oriented_closed_shell(
1211                String::new(),
1212                m::ClosedShellRef::ClosedShell(shell),
1213                false,
1214            )?;
1215            void_refs.push(m::OrientedClosedShellRef::OrientedClosedShell(oriented));
1216        }
1217        let solid = self.author.add_brep_with_voids(
1218            name.to_owned(),
1219            m::ClosedShellRef::ClosedShell(outer),
1220            void_refs,
1221        )?;
1222        self.parts[part.0]
1223            .items
1224            .push(m::RepresentationItemRef::BrepWithVoids(solid));
1225        Ok(solid)
1226    }
1227
1228    /// Add one part: the full product chain (product → formation →
1229    /// definition → definition shape) plus its origin placement. Shape items
1230    /// collected for the part are bound into its shape representation by
1231    /// [`finish`](Self::finish).
1232    ///
1233    /// # Errors
1234    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1235    /// here is fixed, so an error indicates a bug in the builder itself.
1236    pub fn part(&mut self, name: &str) -> Result<Part, AuthorError> {
1237        self.part_with(name, &PartOptions::default())
1238    }
1239
1240    /// [`part`](Self::part) with metadata overrides: part number, version
1241    /// label, and the description fields.
1242    ///
1243    /// # Errors
1244    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1245    /// here is fixed, so an error indicates a bug in the builder itself.
1246    pub fn part_with(&mut self, name: &str, opts: &PartOptions) -> Result<Part, AuthorError> {
1247        let origin = self.placement(&Frame {
1248            origin: [0.0; 3],
1249            axis: [0.0, 0.0, 1.0],
1250            ref_dir: [1.0, 0.0, 0.0],
1251        })?;
1252        let a = &mut self.author;
1253        let product = a.add_product(
1254            opts.id.clone().unwrap_or_else(|| name.to_owned()),
1255            name.to_owned(),
1256            opts.description.clone(),
1257            vec![m::ProductContextRef::ProductContext(self.product_context)],
1258        )?;
1259        let formation = a.add_product_definition_formation(
1260            opts.version.clone().unwrap_or_default(),
1261            opts.version_description.clone(),
1262            m::ProductRef::Product(product),
1263        )?;
1264        let definition = a.add_product_definition(
1265            "design".to_owned(),
1266            opts.definition_description.clone(),
1267            m::ProductDefinitionFormationRef::ProductDefinitionFormation(formation),
1268            m::ProductDefinitionContextRef::ProductDefinitionContext(self.pd_context),
1269        )?;
1270        let shape = a.add_product_definition_shape(
1271            String::new(),
1272            None,
1273            m::CharacterizedDefinitionRef::ProductDefinition(definition),
1274        )?;
1275
1276        self.parts.push(PendingPart {
1277            product,
1278            formation,
1279            definition,
1280            shape,
1281            origin,
1282            items: Vec::new(),
1283            meshes: Vec::new(),
1284        });
1285        Ok(Part(self.parts.len() - 1))
1286    }
1287
1288    /// Attach a display mesh to `part`, optionally linked to the b-rep solid
1289    /// it tessellates (the link is what `MeshGroup::solid()` resolves on the
1290    /// read side). Triangle indices are 0-based into `points`; the builder
1291    /// converts to STEP's 1-based strip encoding. The mesh lands in its own
1292    /// `TESSELLATED_SHAPE_REPRESENTATION` on [`finish`](Self::finish).
1293    ///
1294    /// # Errors
1295    /// An id that did not come from this builder (another builder's, or out
1296    /// of range) fails validation with [`AuthorError`]; beyond that the
1297    /// wiring is fixed, so an error indicates a bug in the builder itself.
1298    pub fn mesh(
1299        &mut self,
1300        part: Part,
1301        name: &str,
1302        input: &MeshInput,
1303        of_solid: Option<SolidRef>,
1304    ) -> Result<m::TessellatedSolidId, AuthorError> {
1305        let a = &mut self.author;
1306        let npoints = i64::try_from(input.points.len()).unwrap_or(i64::MAX);
1307        let coordinates = a.add_coordinates_list(
1308            String::new(),
1309            npoints,
1310            input.points.iter().map(|p| p.to_vec()).collect(),
1311        )?;
1312        let normals: Vec<Vec<f64>> = match &input.normals {
1313            MeshNormalsInput::None => Vec::new(),
1314            MeshNormalsInput::Uniform(n) => vec![n.to_vec()],
1315            MeshNormalsInput::PerVertex(rows) => rows.iter().map(|n| n.to_vec()).collect(),
1316        };
1317        // One 3-index strip per triangle, 1-based. The read side's strip
1318        // decode swaps the last two indices of the first window, so encode
1319        // (p, q, r) as [p, r, q] to get the original winding back.
1320        let to_ix = |v: usize| i64::try_from(v + 1).unwrap_or(i64::MAX);
1321        let triangle_strips: Vec<Vec<i64>> = input
1322            .triangles
1323            .iter()
1324            .map(|&[p, q, r]| vec![to_ix(p), to_ix(r), to_ix(q)])
1325            .collect();
1326        let face = a.add_complex_triangulated_face(
1327            name.to_owned(),
1328            m::CoordinatesListRef::CoordinatesList(coordinates),
1329            npoints,
1330            normals,
1331            None,
1332            Vec::new(),
1333            triangle_strips,
1334            Vec::new(),
1335        )?;
1336        let solid = a.add_tessellated_solid(
1337            name.to_owned(),
1338            vec![m::TessellatedStructuredItemRef::ComplexTriangulatedFace(
1339                face,
1340            )],
1341            of_solid.map(|s| match s {
1342                SolidRef::Solid(id) => m::ManifoldSolidBrepRef::ManifoldSolidBrep(id),
1343                SolidRef::VoidSolid(id) => m::ManifoldSolidBrepRef::BrepWithVoids(id),
1344            }),
1345        )?;
1346        self.parts[part.0].meshes.push(solid);
1347        Ok(solid)
1348    }
1349
1350    /// A person at an organization — reusable across
1351    /// [`contributor`](Self::contributor) and approver roles.
1352    ///
1353    /// # Errors
1354    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1355    /// here is fixed, so an error indicates a bug in the builder itself.
1356    pub fn person_and_org(
1357        &mut self,
1358        p: &PersonOrg,
1359    ) -> Result<m::PersonAndOrganizationId, AuthorError> {
1360        let a = &mut self.author;
1361        let person = a.add_person(
1362            p.person_id.clone(),
1363            p.last_name.clone(),
1364            p.first_name.clone(),
1365            None,
1366            None,
1367            None,
1368        )?;
1369        let organization = a.add_organization(None, p.organization.clone(), None)?;
1370        a.add_person_and_organization(
1371            m::PersonRef::Person(person),
1372            m::OrganizationRef::Organization(organization),
1373        )
1374    }
1375
1376    /// Record `who` as a contributor to the part's version under the given
1377    /// role (customary roles: `"creator"`, `"design_owner"`, `"checker"`).
1378    ///
1379    /// # Errors
1380    /// An id that did not come from this builder (another builder's, or out
1381    /// of range) fails validation with [`AuthorError`]; beyond that the
1382    /// wiring is fixed, so an error indicates a bug in the builder itself.
1383    pub fn contributor(
1384        &mut self,
1385        part: Part,
1386        role: &str,
1387        who: m::PersonAndOrganizationId,
1388    ) -> Result<(), AuthorError> {
1389        let a = &mut self.author;
1390        let role = a.add_person_and_organization_role(role.to_owned())?;
1391        a.add_applied_person_and_organization_assignment(
1392            m::PersonAndOrganizationRef::PersonAndOrganization(who),
1393            m::PersonAndOrganizationRoleRef::PersonAndOrganizationRole(role),
1394            vec![m::PersonAndOrganizationItemRef::ProductDefinitionFormation(
1395                self.parts[part.0].formation,
1396            )],
1397        )?;
1398        Ok(())
1399    }
1400
1401    /// Attach an approval to the part's version: a status word, a level,
1402    /// any approvers with their roles, and an optional approval date.
1403    ///
1404    /// # Errors
1405    /// An id that did not come from this builder (another builder's, or out
1406    /// of range) fails validation with [`AuthorError`]; beyond that the
1407    /// wiring is fixed, so an error indicates a bug in the builder itself.
1408    pub fn approve(
1409        &mut self,
1410        part: Part,
1411        input: &ApprovalInput,
1412    ) -> Result<m::ApprovalId, AuthorError> {
1413        let a = &mut self.author;
1414        let status = a.add_approval_status(input.status.clone())?;
1415        let approval = a.add_approval(
1416            m::ApprovalStatusRef::ApprovalStatus(status),
1417            input.level.clone(),
1418        )?;
1419        a.add_applied_approval_assignment(
1420            m::ApprovalRef::Approval(approval),
1421            vec![m::ApprovalItemRef::ProductDefinitionFormation(
1422                self.parts[part.0].formation,
1423            )],
1424        )?;
1425        for (who, role) in &input.approvers {
1426            let role = a.add_approval_role(role.clone())?;
1427            a.add_approval_person_organization(
1428                m::PersonOrganizationSelectRef::PersonAndOrganization(*who),
1429                m::ApprovalRef::Approval(approval),
1430                m::ApprovalRoleRef::ApprovalRole(role),
1431            )?;
1432        }
1433        if let Some(date) = input.date {
1434            let calendar = a.add_calendar_date(date.year, date.day, date.month)?;
1435            let zone = a.add_coordinated_universal_time_offset(0, None, m::AheadOrBehind::Exact)?;
1436            let time = a.add_local_time(
1437                date.hour,
1438                date.minute,
1439                None,
1440                m::CoordinatedUniversalTimeOffsetRef::CoordinatedUniversalTimeOffset(zone),
1441            )?;
1442            let date_time = a.add_date_and_time(
1443                m::DateRef::CalendarDate(calendar),
1444                m::LocalTimeRef::LocalTime(time),
1445            )?;
1446            a.add_approval_date_time(
1447                m::DateTimeSelectRef::DateAndTime(date_time),
1448                m::ApprovalRef::Approval(approval),
1449            )?;
1450        }
1451        Ok(approval)
1452    }
1453
1454    /// Attach an external document reference to the part's version.
1455    ///
1456    /// # Errors
1457    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1458    /// here is fixed, so an error indicates a bug in the builder itself.
1459    pub fn document(&mut self, part: Part, d: &DocumentInput) -> Result<(), AuthorError> {
1460        let a = &mut self.author;
1461        let kind = a.add_document_type(d.kind.clone())?;
1462        let doc = a.add_document(
1463            d.id.clone(),
1464            d.name.clone(),
1465            d.description.clone(),
1466            m::DocumentTypeRef::DocumentType(kind),
1467        )?;
1468        a.add_applied_document_reference(
1469            m::DocumentRef::Document(doc),
1470            String::new(),
1471            vec![m::DocumentReferenceItemRef::ProductDefinitionFormation(
1472                self.parts[part.0].formation,
1473            )],
1474        )?;
1475        Ok(())
1476    }
1477
1478    /// Attach a security classification to the part's version.
1479    ///
1480    /// # Errors
1481    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1482    /// here is fixed, so an error indicates a bug in the builder itself.
1483    pub fn security(
1484        &mut self,
1485        part: Part,
1486        name: &str,
1487        purpose: &str,
1488        level: &str,
1489    ) -> Result<(), AuthorError> {
1490        let a = &mut self.author;
1491        let level = a.add_security_classification_level(level.to_owned())?;
1492        let classification = a.add_security_classification(
1493            name.to_owned(),
1494            purpose.to_owned(),
1495            m::SecurityClassificationLevelRef::SecurityClassificationLevel(level),
1496        )?;
1497        a.add_applied_security_classification_assignment(
1498            m::SecurityClassificationRef::SecurityClassification(classification),
1499            vec![
1500                m::SecurityClassificationItemRef::ProductDefinitionFormation(
1501                    self.parts[part.0].formation,
1502                ),
1503            ],
1504        )?;
1505        Ok(())
1506    }
1507
1508    /// Place `child` inside `parent` at the given frame — one assembly
1509    /// occurrence (the same child may be placed any number of times; each
1510    /// call is one instance). The usage occurrence is created immediately;
1511    /// its shape wiring is bound in [`finish`](Self::finish).
1512    ///
1513    /// # Errors
1514    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1515    /// here is fixed, so an error indicates a bug in the builder itself.
1516    pub fn place(
1517        &mut self,
1518        parent: Part,
1519        child: Part,
1520        at: Frame,
1521    ) -> Result<m::NextAssemblyUsageOccurrenceId, AuthorError> {
1522        let nauo = self.author.add_next_assembly_usage_occurrence(
1523            format!("{}", self.placements.len() + 1),
1524            String::new(),
1525            None,
1526            m::ProductDefinitionOrReferenceRef::ProductDefinition(self.parts[parent.0].definition),
1527            m::ProductDefinitionOrReferenceRef::ProductDefinition(self.parts[child.0].definition),
1528            None,
1529        )?;
1530        let to = self.placement(&at)?;
1531        self.placements.push(PendingPlacement {
1532            nauo,
1533            parent: parent.0,
1534            child: child.0,
1535            to,
1536        });
1537        Ok(nauo)
1538    }
1539
1540    /// Materialize each part's shape representation (its origin placement
1541    /// plus the collected items) with its shape-definition link, add the
1542    /// customary product category, and emit the Part 21 text.
1543    ///
1544    /// # Errors
1545    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1546    /// here is fixed, so an error indicates a bug in the builder itself.
1547    pub fn finish(mut self) -> Result<String, AuthorError> {
1548        let a = &mut self.author;
1549        // Pass 1: one shape representation per part (a part carrying a solid
1550        // is emitted as the customary ADVANCED_BREP_SHAPE_REPRESENTATION),
1551        // kept by part index for the placement wiring below.
1552        let mut part_reps: Vec<m::RepresentationOrRepresentationReferenceRef> =
1553            Vec::with_capacity(self.parts.len());
1554        for part in &self.parts {
1555            let mut items = vec![m::RepresentationItemRef::Axis2Placement3d(part.origin)];
1556            items.extend(part.items.iter().cloned());
1557            let has_solid = part.items.iter().any(|i| {
1558                matches!(
1559                    i,
1560                    m::RepresentationItemRef::ManifoldSolidBrep(_)
1561                        | m::RepresentationItemRef::BrepWithVoids(_)
1562                )
1563            });
1564            let (rep, rep_or_ref) = if has_solid {
1565                let id = a.add_advanced_brep_shape_representation(
1566                    String::new(),
1567                    items,
1568                    m::RepresentationContextRef::Complex(self.ctx),
1569                )?;
1570                (
1571                    m::RepresentationRef::AdvancedBrepShapeRepresentation(id),
1572                    m::RepresentationOrRepresentationReferenceRef::AdvancedBrepShapeRepresentation(
1573                        id,
1574                    ),
1575                )
1576            } else {
1577                let id = a.add_shape_representation(
1578                    String::new(),
1579                    items,
1580                    m::RepresentationContextRef::Complex(self.ctx),
1581                )?;
1582                (
1583                    m::RepresentationRef::ShapeRepresentation(id),
1584                    m::RepresentationOrRepresentationReferenceRef::ShapeRepresentation(id),
1585                )
1586            };
1587            a.add_shape_definition_representation(
1588                m::RepresentedDefinitionRef::ProductDefinitionShape(part.shape),
1589                rep,
1590            )?;
1591            part_reps.push(rep_or_ref);
1592
1593            // A part with display meshes gets its own tessellated
1594            // representation alongside the shape one (multi-SDR per shape).
1595            if !part.meshes.is_empty() {
1596                let tsr = a.add_tessellated_shape_representation(
1597                    String::new(),
1598                    part.meshes
1599                        .iter()
1600                        .map(|&t| m::RepresentationItemRef::TessellatedSolid(t))
1601                        .collect(),
1602                    m::RepresentationContextRef::Complex(self.ctx),
1603                )?;
1604                a.add_shape_definition_representation(
1605                    m::RepresentedDefinitionRef::ProductDefinitionShape(part.shape),
1606                    m::RepresentationRef::TessellatedShapeRepresentation(tsr),
1607                )?;
1608            }
1609        }
1610        // Pass 2: assembly placements.
1611        bind_placements(a, &self.parts, &self.placements, &part_reps)?;
1612        if !self.parts.is_empty() {
1613            a.add_product_related_product_category(
1614                "part".to_owned(),
1615                None,
1616                self.parts
1617                    .iter()
1618                    .map(|p| m::ProductRef::Product(p.product))
1619                    .collect(),
1620            )?;
1621        }
1622        // Hidden items (individual styled items and whole layers) collect
1623        // into one INVISIBILITY.
1624        if !self.hidden.is_empty() {
1625            a.add_invisibility(std::mem::take(&mut self.hidden))?;
1626        }
1627        // Styles are anchored in the customary presentation representation.
1628        if !self.styles.is_empty() {
1629            a.add_mechanical_design_geometric_presentation_representation(
1630                String::new(),
1631                self.styles
1632                    .iter()
1633                    .map(|&s| m::RepresentationItemRef::StyledItem(s))
1634                    .collect(),
1635                m::RepresentationContextRef::Complex(self.ctx),
1636            )?;
1637        }
1638        let h = &self.header;
1639        let file_header = FileHeader {
1640            description: h.description.clone().unwrap_or_default(),
1641            file_name: h.file_name.clone().unwrap_or_default(),
1642            time_stamp: h.timestamp.clone().unwrap_or_else(now_iso_utc),
1643            authors: h.authors.clone(),
1644            organizations: h.organizations.clone(),
1645            preprocessor_version: concat!("step-io ", env!("CARGO_PKG_VERSION")).to_owned(),
1646            originating_system: h.originating_system.clone().unwrap_or_default(),
1647            authorisation: h.authorisation.clone().unwrap_or_default(),
1648            // The authoring layer stamps the AP242 schema identity in
1649            // `finish_with_header`; whatever is set here is overwritten.
1650            schema: crate::parser::SchemaId::default(),
1651        };
1652        Ok(self.author.finish_with_header(&file_header))
1653    }
1654}
1655
1656/// Assembly-placement wiring, deferred to `finish`: each occurrence gets its
1657/// placement shape (PDS over the NAUO) bound to an item-defined
1658/// transformation (item 1 = parent origin/from, item 2 = child placement/to)
1659/// through the customary relationship complex (`rep_1` = child, `rep_2` =
1660/// parent).
1661fn bind_placements(
1662    a: &mut Ap242Author,
1663    parts: &[PendingPart],
1664    placements: &[PendingPlacement],
1665    part_reps: &[m::RepresentationOrRepresentationReferenceRef],
1666) -> Result<(), AuthorError> {
1667    for placement in placements {
1668        let pds = a.add_product_definition_shape(
1669            "Placement".to_owned(),
1670            Some("Placement of an item".to_owned()),
1671            m::CharacterizedDefinitionRef::NextAssemblyUsageOccurrence(placement.nauo),
1672        )?;
1673        let idt = a.add_item_defined_transformation(
1674            String::new(),
1675            None,
1676            m::RepresentationItemRef::Axis2Placement3d(parts[placement.parent].origin),
1677            m::RepresentationItemRef::Axis2Placement3d(placement.to),
1678        )?;
1679        let rrwt = a.add_complex(vec![
1680            m::UnitPart::RepresentationRelationship {
1681                name: String::new(),
1682                description: None,
1683                rep_1: part_reps[placement.child].clone(),
1684                rep_2: part_reps[placement.parent].clone(),
1685            },
1686            m::UnitPart::RepresentationRelationshipWithTransformation {
1687                transformation_operator: m::TransformationRef::ItemDefinedTransformation(idt),
1688            },
1689            m::UnitPart::ShapeRepresentationRelationship,
1690        ])?;
1691        a.add_context_dependent_shape_representation(
1692            m::ShapeRepresentationRelationshipRef::Complex(rrwt),
1693            m::ProductDefinitionShapeRef::ProductDefinitionShape(pds),
1694        )?;
1695    }
1696    Ok(())
1697}
1698
1699#[cfg(test)]
1700mod tests {
1701    use super::iso_utc_from_unix;
1702
1703    #[test]
1704    fn iso_utc_from_unix_known_values() {
1705        assert_eq!(iso_utc_from_unix(0), "1970-01-01T00:00:00Z");
1706        // Leap-day boundary: 2024-02-29 23:59:59 UTC.
1707        assert_eq!(iso_utc_from_unix(1_709_251_199), "2024-02-29T23:59:59Z");
1708        // The day after: 2024-03-01 00:00:00 UTC.
1709        assert_eq!(iso_utc_from_unix(1_709_251_200), "2024-03-01T00:00:00Z");
1710    }
1711}