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/// Which way the face normals of the void shells passed to
439/// [`StepBuilder::solid_with_voids`] point. The caller declares this so
440/// step-io can orient each cavity's `ORIENTED_CLOSED_SHELL` correctly without
441/// evaluating geometry. See that method for how to choose.
442#[derive(Debug, Clone, Copy)]
443pub enum VoidShellNormals {
444    /// Normals point away from the solid material — for a cavity, into the
445    /// empty hole. This is the same rule the outer shell follows, the
446    /// validator- and mass-property-friendly orientation, and what most
447    /// kernels emit for a cavity (a reversed shell). Kept as authored,
448    /// written as `ORIENTED_CLOSED_SHELL(..., .T.)`. The right choice when unsure.
449    AwayFromMaterial,
450    /// Normals point into the solid material — a cavity wound like an ordinary
451    /// outward-facing solid box dropped into the hole. step-io reverses it,
452    /// written as `ORIENTED_CLOSED_SHELL(..., .F.)`.
453    TowardMaterial,
454}
455
456/// Handle for a vertex created by [`StepBuilder::vertex`] — an index into
457/// the builder that created it (a different builder's handle panics or
458/// targets the wrong vertex). The builder keeps the vertex position so
459/// straight edges can derive their line geometry.
460#[derive(Debug, Copy, Clone, PartialEq, Eq)]
461pub struct Vertex(usize);
462
463/// Handle for a part created by [`StepBuilder::part`] — an index into the
464/// builder that created it. Passing it to a different builder panics (out
465/// of range) or silently targets the wrong part (in range).
466#[derive(Debug, Copy, Clone, PartialEq, Eq)]
467pub struct Part(usize);
468
469/// A part's product chain plus the shape items collected for it; the shape
470/// representation itself is materialized in [`StepBuilder::finish`] because
471/// representation items are fixed at insertion.
472struct PendingPart {
473    product: m::ProductId,
474    formation: m::ProductDefinitionFormationId,
475    definition: m::ProductDefinitionId,
476    shape: m::ProductDefinitionShapeId,
477    origin: m::Axis2Placement3dId,
478    items: Vec<m::RepresentationItemRef>,
479    meshes: Vec<m::TessellatedSolidId>,
480}
481
482/// One [`StepBuilder::place`] call: the eagerly-created usage occurrence
483/// plus the wiring deferred to [`StepBuilder::finish`] (the transformation
484/// relationship needs both parts' shape representations, which exist only
485/// then).
486struct PendingPlacement {
487    nauo: m::NextAssemblyUsageOccurrenceId,
488    parent: usize,
489    child: usize,
490    to: m::Axis2Placement3dId,
491}
492
493/// Ergonomic writer for the standard AP242 file shape: shared contexts and
494/// units up front, one product chain per [`part`](Self::part), everything
495/// bound and emitted by [`finish`](Self::finish).
496pub struct StepBuilder {
497    author: Ap242Author,
498    ctx: m::ComplexUnitId,
499    product_context: m::ProductContextId,
500    pd_context: m::ProductDefinitionContextId,
501    parts: Vec<PendingPart>,
502    placements: Vec<PendingPlacement>,
503    vertices: Vec<(m::VertexPointId, [f64; 3])>,
504    styles: Vec<m::StyledItemId>,
505    hidden: Vec<m::InvisibleItemRef>,
506    header: HeaderInput,
507}
508
509impl StepBuilder {
510    /// Set up the shared skeleton with the default units (millimetre,
511    /// 1e-7 uncertainty) — see [`new_with`](Self::new_with) to choose.
512    ///
513    /// # Errors
514    /// Propagates [`AuthorError`] from the strict constructors; the wiring
515    /// here is fixed, so an error indicates a bug in the builder itself.
516    pub fn new() -> Result<Self, AuthorError> {
517        Self::new_with(&UnitsInput::default())
518    }
519
520    /// Set up the shared skeleton: application context + protocol definition
521    /// (the AP242 identity values), product contexts, and the geometric
522    /// context complex carrying the chosen SI length unit, radian,
523    /// steradian, and the given length uncertainty.
524    ///
525    /// # Errors
526    /// Propagates [`AuthorError`] from the strict constructors; the wiring
527    /// here is fixed, so an error indicates a bug in the builder itself.
528    pub fn new_with(units: &UnitsInput) -> Result<Self, AuthorError> {
529        let mut a = Ap242Author::new();
530
531        let ac = a.add_application_context("managed model based 3d engineering".to_owned())?;
532        a.add_application_protocol_definition(
533            "international standard".to_owned(),
534            "ap242_managed_model_based_3d_engineering_mim_lf".to_owned(),
535            2011,
536            m::ApplicationContextRef::ApplicationContext(ac),
537        )?;
538        let product_context = a.add_product_context(
539            String::new(),
540            m::ApplicationContextRef::ApplicationContext(ac),
541            "mechanical".to_owned(),
542        )?;
543        let pd_context = a.add_product_definition_context(
544            "part definition".to_owned(),
545            m::ApplicationContextRef::ApplicationContext(ac),
546            "design".to_owned(),
547        )?;
548
549        let length_prefix = match units.length {
550            LengthUnit::Millimetre => Some(m::SiPrefix::Milli),
551            LengthUnit::Metre => None,
552        };
553        let mm = a.add_complex(vec![
554            m::UnitPart::LengthUnit,
555            m::UnitPart::NamedUnit { dimensions: None },
556            m::UnitPart::SiUnit {
557                prefix: length_prefix,
558                name: m::SiUnitName::Metre,
559            },
560        ])?;
561        let rad = a.add_complex(vec![
562            m::UnitPart::NamedUnit { dimensions: None },
563            m::UnitPart::PlaneAngleUnit,
564            m::UnitPart::SiUnit {
565                prefix: None,
566                name: m::SiUnitName::Radian,
567            },
568        ])?;
569        let sr = a.add_complex(vec![
570            m::UnitPart::NamedUnit { dimensions: None },
571            m::UnitPart::SiUnit {
572                prefix: None,
573                name: m::SiUnitName::Steradian,
574            },
575            m::UnitPart::SolidAngleUnit,
576        ])?;
577        let uncertainty = a.add_uncertainty_measure_with_unit(
578            m::MeasureValue {
579                type_name: Some("LENGTH_MEASURE".to_owned()),
580                value: m::MeasureScalar::Real(units.uncertainty),
581            },
582            m::UnitRef::Complex(mm),
583            "distance_accuracy_value".to_owned(),
584            Some("confusion accuracy".to_owned()),
585        )?;
586        let ctx = a.add_complex(vec![
587            m::UnitPart::GeometricRepresentationContext {
588                coordinate_space_dimension: 3,
589            },
590            m::UnitPart::GlobalUncertaintyAssignedContext {
591                uncertainty: vec![
592                    m::UncertaintyMeasureWithUnitRef::UncertaintyMeasureWithUnit(uncertainty),
593                ],
594            },
595            m::UnitPart::GlobalUnitAssignedContext {
596                units: vec![
597                    m::UnitRef::Complex(mm),
598                    m::UnitRef::Complex(rad),
599                    m::UnitRef::Complex(sr),
600                ],
601            },
602            m::UnitPart::RepresentationContext {
603                context_identifier: String::new(),
604                context_type: "3D".to_owned(),
605            },
606        ])?;
607
608        Ok(Self {
609            author: a,
610            ctx,
611            product_context,
612            pd_context,
613            parts: Vec::new(),
614            placements: Vec::new(),
615            vertices: Vec::new(),
616            styles: Vec::new(),
617            hidden: Vec::new(),
618            header: HeaderInput::default(),
619        })
620    }
621
622    /// Set the Part 21 HEADER fields (file name, description, authors,
623    /// organizations, originating system, authorisation, timestamp) applied
624    /// on [`finish`](Self::finish). May be called any time before `finish`;
625    /// the last call wins. Without it the header carries only the automatic
626    /// timestamp and preprocessor stamp.
627    pub fn header(&mut self, h: &HeaderInput) {
628        self.header = h.clone();
629    }
630
631    /// Escape hatch to the strict low-level layer — the write-side
632    /// counterpart of the read side's `Scene::model()`. Entities the builder
633    /// does not cover go through [`Ap242Author`]'s generated constructors
634    /// directly; ids cross freely in both directions (builder-produced ids
635    /// feed low-level calls and vice versa — same model, same validation).
636    ///
637    /// Only borrowed access is offered: taking the author out would bypass
638    /// [`finish`](Self::finish), which still has to materialize the pending
639    /// shape representations, placements, style anchors, category, and
640    /// header.
641    pub fn author(&mut self) -> &mut Ap242Author {
642        &mut self.author
643    }
644
645    /// Colour `target` (a solid or one face), optionally with a transparency
646    /// (`0.0` = opaque, `1.0` = fully transparent). Styling the same target
647    /// again adds another style record; readers use the first they find.
648    ///
649    /// # Errors
650    /// An id that did not come from this builder (another builder's, or out
651    /// of range) fails validation with [`AuthorError`]; beyond that the
652    /// wiring is fixed, so an error indicates a bug in the builder itself.
653    pub fn style(
654        &mut self,
655        target: StyleTarget,
656        color: Rgb,
657        transparency: Option<f64>,
658    ) -> Result<m::StyledItemId, AuthorError> {
659        let a = &mut self.author;
660        let rgb = a.add_colour_rgb(String::new(), color.red, color.green, color.blue)?;
661        // Colour alone rides the customary fill-area chain; with a
662        // transparency the rendering form carries both.
663        let element = if let Some(t) = transparency {
664            let transparent = a.add_surface_style_transparent(t)?;
665            let rendering = a.add_surface_style_rendering_with_properties(
666                m::ShadingSurfaceMethod::NormalShading,
667                m::ColourRef::ColourRgb(rgb),
668                vec![m::RenderingPropertiesSelectRef::SurfaceStyleTransparent(
669                    transparent,
670                )],
671            )?;
672            m::SurfaceStyleElementSelectRef::SurfaceStyleRenderingWithProperties(rendering)
673        } else {
674            let fill_colour =
675                a.add_fill_area_style_colour(String::new(), m::ColourRef::ColourRgb(rgb))?;
676            let fill_style = a.add_fill_area_style(
677                String::new(),
678                vec![m::FillStyleSelectRef::FillAreaStyleColour(fill_colour)],
679            )?;
680            let fill_area =
681                a.add_surface_style_fill_area(m::FillAreaStyleRef::FillAreaStyle(fill_style))?;
682            m::SurfaceStyleElementSelectRef::SurfaceStyleFillArea(fill_area)
683        };
684        let side_style = a.add_surface_side_style(String::new(), vec![element])?;
685        let usage = a.add_surface_style_usage(
686            m::SurfaceSide::Both,
687            m::SurfaceSideStyleSelectRef::SurfaceSideStyle(side_style),
688        )?;
689        let assignment = a.add_presentation_style_assignment(vec![
690            m::PresentationStyleSelectRef::SurfaceStyleUsage(usage),
691        ])?;
692        let item = match target {
693            StyleTarget::Face(f) => m::StyledItemTargetRef::AdvancedFace(f),
694            StyleTarget::Solid(s) => m::StyledItemTargetRef::ManifoldSolidBrep(s),
695            StyleTarget::VoidSolid(s) => m::StyledItemTargetRef::BrepWithVoids(s),
696        };
697        let styled = a.add_styled_item(
698            String::new(),
699            vec![m::PresentationStyleAssignmentRef::PresentationStyleAssignment(assignment)],
700            item,
701        )?;
702        self.styles.push(styled);
703        Ok(styled)
704    }
705
706    /// Assign `targets` to a named presentation layer; the returned id can
707    /// be fed to [`hide_layer`](Self::hide_layer) to switch the whole layer
708    /// off by default.
709    ///
710    /// # Errors
711    /// An id that did not come from this builder (another builder's, or out
712    /// of range) fails validation with [`AuthorError`]; beyond that the
713    /// wiring is fixed, so an error indicates a bug in the builder itself.
714    pub fn layer(
715        &mut self,
716        name: &str,
717        targets: Vec<StyleTarget>,
718    ) -> Result<m::PresentationLayerAssignmentId, AuthorError> {
719        let items = targets
720            .into_iter()
721            .map(|t| match t {
722                StyleTarget::Face(f) => m::LayeredItemRef::AdvancedFace(f),
723                StyleTarget::Solid(s) => m::LayeredItemRef::ManifoldSolidBrep(s),
724                StyleTarget::VoidSolid(s) => m::LayeredItemRef::BrepWithVoids(s),
725            })
726            .collect();
727        self.author
728            .add_presentation_layer_assignment(name.to_owned(), String::new(), items)
729    }
730
731    /// Mark `target` invisible by default. Implemented the way STEP requires
732    /// it: an `INVISIBILITY` cannot point at geometry directly, so the
733    /// builder attaches an empty (`.NULL.`-styled) styled item to the target
734    /// and registers that — colour styles on the same target are unaffected.
735    ///
736    /// # Errors
737    /// An id that did not come from this builder (another builder's, or out
738    /// of range) fails validation with [`AuthorError`]; beyond that the
739    /// wiring is fixed, so an error indicates a bug in the builder itself.
740    pub fn hide(&mut self, target: StyleTarget) -> Result<(), AuthorError> {
741        let a = &mut self.author;
742        let assignment =
743            a.add_presentation_style_assignment(vec![m::PresentationStyleSelectRef::NullStyle(
744                m::NullStyle::Null,
745            )])?;
746        let item = match target {
747            StyleTarget::Face(f) => m::StyledItemTargetRef::AdvancedFace(f),
748            StyleTarget::Solid(s) => m::StyledItemTargetRef::ManifoldSolidBrep(s),
749            StyleTarget::VoidSolid(s) => m::StyledItemTargetRef::BrepWithVoids(s),
750        };
751        let styled = a.add_styled_item(
752            String::new(),
753            vec![m::PresentationStyleAssignmentRef::PresentationStyleAssignment(assignment)],
754            item,
755        )?;
756        self.styles.push(styled);
757        self.hidden.push(m::InvisibleItemRef::StyledItem(styled));
758        Ok(())
759    }
760
761    /// Switch a whole layer (from [`layer`](Self::layer)) invisible by
762    /// default. Infallible at call time — a foreign or stale id is caught by
763    /// the `INVISIBILITY` validation inside [`finish`](Self::finish).
764    pub fn hide_layer(&mut self, layer: m::PresentationLayerAssignmentId) {
765        self.hidden
766            .push(m::InvisibleItemRef::PresentationLayerAssignment(layer));
767    }
768
769    /// `AXIS2_PLACEMENT_3D` from a [`Frame`].
770    fn placement(&mut self, f: &Frame) -> Result<m::Axis2Placement3dId, AuthorError> {
771        let a = &mut self.author;
772        let location = a.add_cartesian_point(String::new(), f.origin.to_vec())?;
773        let axis = a.add_direction(String::new(), f.axis.to_vec())?;
774        let ref_direction = a.add_direction(String::new(), f.ref_dir.to_vec())?;
775        a.add_axis2_placement3d(
776            String::new(),
777            m::CartesianPointRef::CartesianPoint(location),
778            Some(m::DirectionRef::Direction(axis)),
779            Some(m::DirectionRef::Direction(ref_direction)),
780        )
781    }
782
783    /// One `CARTESIAN_POINT` ref per control point.
784    fn control_points(
785        &mut self,
786        row: &[[f64; 3]],
787    ) -> Result<Vec<m::CartesianPointRef>, AuthorError> {
788        row.iter()
789            .map(|p| {
790                self.author
791                    .add_cartesian_point(String::new(), p.to_vec())
792                    .map(m::CartesianPointRef::CartesianPoint)
793            })
794            .collect()
795    }
796
797    /// Geometry for a swept-surface profile (also backs the ellipse edge).
798    fn profile_geometry(&mut self, profile: &ProfileInput) -> Result<m::CurveRef, AuthorError> {
799        match profile {
800            ProfileInput::Line(point, dir) => {
801                let a = &mut self.author;
802                let pnt = a.add_cartesian_point(String::new(), point.to_vec())?;
803                let orientation = a.add_direction(String::new(), dir.to_vec())?;
804                let vector =
805                    a.add_vector(String::new(), m::DirectionRef::Direction(orientation), 1.0)?;
806                let line = a.add_line(
807                    String::new(),
808                    m::CartesianPointRef::CartesianPoint(pnt),
809                    m::VectorRef::Vector(vector),
810                )?;
811                Ok(m::CurveRef::Line(line))
812            }
813            ProfileInput::Circle(frame, radius) => {
814                let position = self.placement(frame)?;
815                let circle = self.author.add_circle(
816                    String::new(),
817                    m::Axis2PlacementRef::Axis2Placement3d(position),
818                    *radius,
819                )?;
820                Ok(m::CurveRef::Circle(circle))
821            }
822            ProfileInput::Ellipse(frame, semi_1, semi_2) => {
823                let position = self.placement(frame)?;
824                let ellipse = self.author.add_ellipse(
825                    String::new(),
826                    m::Axis2PlacementRef::Axis2Placement3d(position),
827                    *semi_1,
828                    *semi_2,
829                )?;
830                Ok(m::CurveRef::Ellipse(ellipse))
831            }
832            ProfileInput::Nurbs(curve) => self.nurbs_curve_geometry(curve),
833        }
834    }
835
836    /// NURBS curve geometry: non-rational (all weights `1.0`) as a plain
837    /// `B_SPLINE_CURVE_WITH_KNOTS`, rational as the customary multi-part
838    /// complex instance.
839    // Exact comparison intended: only weights that are exactly 1.0 take the
840    // non-rational form; anything else keeps its value in weights_data.
841    #[allow(clippy::float_cmp)]
842    fn nurbs_curve_geometry(&mut self, c: &NurbsCurve) -> Result<m::CurveRef, AuthorError> {
843        let cps = self.control_points(&c.control_points)?;
844        let (knots, knot_multiplicities) = compress_knots(&c.knots);
845        let degree = i64::try_from(c.degree).unwrap_or(i64::MAX);
846        if c.weights.iter().all(|w| *w == 1.0) {
847            let id = self.author.add_b_spline_curve_with_knots(
848                String::new(),
849                degree,
850                cps,
851                m::BSplineCurveForm::Unspecified,
852                m::Logical::Unknown,
853                m::Logical::Unknown,
854                knot_multiplicities,
855                knots,
856                m::KnotType::Unspecified,
857            )?;
858            Ok(m::CurveRef::BSplineCurveWithKnots(id))
859        } else {
860            let id = self.author.add_complex(vec![
861                m::UnitPart::BoundedCurve,
862                m::UnitPart::BSplineCurve {
863                    degree,
864                    control_points_list: cps,
865                    curve_form: m::BSplineCurveForm::Unspecified,
866                    closed_curve: m::Logical::Unknown,
867                    self_intersect: m::Logical::Unknown,
868                },
869                m::UnitPart::BSplineCurveWithKnots {
870                    knot_multiplicities,
871                    knots,
872                    knot_spec: m::KnotType::Unspecified,
873                },
874                m::UnitPart::Curve,
875                m::UnitPart::GeometricRepresentationItem,
876                m::UnitPart::RationalBSplineCurve {
877                    weights_data: c.weights.clone(),
878                },
879                m::UnitPart::RepresentationItem {
880                    name: String::new(),
881                },
882            ])?;
883            Ok(m::CurveRef::Complex(id))
884        }
885    }
886
887    /// NURBS surface geometry; see [`Self::nurbs_curve_geometry`].
888    #[allow(clippy::float_cmp)] // exact 1.0 check, as in nurbs_curve_geometry
889    fn nurbs_surface_geometry(&mut self, s: &NurbsSurface) -> Result<m::SurfaceRef, AuthorError> {
890        let mut cps = Vec::with_capacity(s.control_points.len());
891        for row in &s.control_points {
892            cps.push(self.control_points(row)?);
893        }
894        let (u_knots, u_multiplicities) = compress_knots(&s.knots_u);
895        let (v_knots, v_multiplicities) = compress_knots(&s.knots_v);
896        let u_degree = i64::try_from(s.degree_u).unwrap_or(i64::MAX);
897        let v_degree = i64::try_from(s.degree_v).unwrap_or(i64::MAX);
898        if s.weights.iter().flatten().all(|w| *w == 1.0) {
899            let id = self.author.add_b_spline_surface_with_knots(
900                String::new(),
901                u_degree,
902                v_degree,
903                cps,
904                m::BSplineSurfaceForm::Unspecified,
905                m::Logical::Unknown,
906                m::Logical::Unknown,
907                m::Logical::Unknown,
908                u_multiplicities,
909                v_multiplicities,
910                u_knots,
911                v_knots,
912                m::KnotType::Unspecified,
913            )?;
914            Ok(m::SurfaceRef::BSplineSurfaceWithKnots(id))
915        } else {
916            let id = self.author.add_complex(vec![
917                m::UnitPart::BoundedSurface,
918                m::UnitPart::BSplineSurface {
919                    u_degree,
920                    v_degree,
921                    control_points_list: cps,
922                    surface_form: m::BSplineSurfaceForm::Unspecified,
923                    u_closed: m::Logical::Unknown,
924                    v_closed: m::Logical::Unknown,
925                    self_intersect: m::Logical::Unknown,
926                },
927                m::UnitPart::BSplineSurfaceWithKnots {
928                    u_multiplicities,
929                    v_multiplicities,
930                    u_knots,
931                    v_knots,
932                    knot_spec: m::KnotType::Unspecified,
933                },
934                m::UnitPart::GeometricRepresentationItem,
935                m::UnitPart::RationalBSplineSurface {
936                    weights_data: s.weights.clone(),
937                },
938                m::UnitPart::RepresentationItem {
939                    name: String::new(),
940                },
941                m::UnitPart::Surface,
942            ])?;
943            Ok(m::SurfaceRef::Complex(id))
944        }
945    }
946
947    /// Add a topological vertex at `p`.
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 vertex(&mut self, p: [f64; 3]) -> Result<Vertex, AuthorError> {
953        let point = self.author.add_cartesian_point(String::new(), p.to_vec())?;
954        let vp = self
955            .author
956            .add_vertex_point(String::new(), m::PointRef::CartesianPoint(point))?;
957        self.vertices.push((vp, p));
958        Ok(Vertex(self.vertices.len() - 1))
959    }
960
961    /// Add an edge between two vertices over the given curve. A straight
962    /// edge derives its line from the vertex positions (a zero-length
963    /// segment gets a placeholder direction — degenerate geometry is the
964    /// caller's responsibility); a circle with `from == to` is a closed
965    /// full-circle edge.
966    ///
967    /// # Errors
968    /// Propagates [`AuthorError`] from the strict constructors; the wiring
969    /// here is fixed, so an error indicates a bug in the builder itself.
970    pub fn edge(
971        &mut self,
972        from: Vertex,
973        to: Vertex,
974        curve: CurveInput,
975    ) -> Result<m::EdgeCurveId, AuthorError> {
976        let (start_vertex, start) = self.vertices[from.0];
977        let (end_vertex, end) = self.vertices[to.0];
978        let geometry = match curve {
979            CurveInput::Line => {
980                let delta = [end[0] - start[0], end[1] - start[1], end[2] - start[2]];
981                let len = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
982                let dir = if len < 1e-12 {
983                    [0.0, 0.0, 1.0]
984                } else {
985                    [delta[0] / len, delta[1] / len, delta[2] / len]
986                };
987                let a = &mut self.author;
988                let pnt = a.add_cartesian_point(String::new(), start.to_vec())?;
989                let orientation = a.add_direction(String::new(), dir.to_vec())?;
990                let vector =
991                    a.add_vector(String::new(), m::DirectionRef::Direction(orientation), len)?;
992                let line = a.add_line(
993                    String::new(),
994                    m::CartesianPointRef::CartesianPoint(pnt),
995                    m::VectorRef::Vector(vector),
996                )?;
997                m::CurveRef::Line(line)
998            }
999            CurveInput::Circle(frame, radius) => {
1000                let position = self.placement(&frame)?;
1001                let circle = self.author.add_circle(
1002                    String::new(),
1003                    m::Axis2PlacementRef::Axis2Placement3d(position),
1004                    radius,
1005                )?;
1006                m::CurveRef::Circle(circle)
1007            }
1008            CurveInput::Ellipse(frame, semi_1, semi_2) => {
1009                self.profile_geometry(&ProfileInput::Ellipse(frame, semi_1, semi_2))?
1010            }
1011            CurveInput::Polyline(points) => {
1012                let refs = self.control_points(&points)?;
1013                m::CurveRef::Polyline(self.author.add_polyline(String::new(), refs)?)
1014            }
1015            CurveInput::Nurbs(curve) => self.nurbs_curve_geometry(&curve)?,
1016        };
1017        self.author.add_edge_curve(
1018            String::new(),
1019            m::VertexRef::VertexPoint(start_vertex),
1020            m::VertexRef::VertexPoint(end_vertex),
1021            geometry,
1022            true,
1023        )
1024    }
1025
1026    /// Emit the geometry entity for a [`SurfaceInput`].
1027    fn surface_geometry(&mut self, surface: SurfaceInput) -> Result<m::SurfaceRef, AuthorError> {
1028        Ok(match surface {
1029            SurfaceInput::Plane(frame) => {
1030                let position = self.placement(&frame)?;
1031                let plane = self.author.add_plane(
1032                    String::new(),
1033                    m::Axis2Placement3dRef::Axis2Placement3d(position),
1034                )?;
1035                m::SurfaceRef::Plane(plane)
1036            }
1037            SurfaceInput::Cylinder(frame, radius) => {
1038                let position = self.placement(&frame)?;
1039                let cyl = self.author.add_cylindrical_surface(
1040                    String::new(),
1041                    m::Axis2Placement3dRef::Axis2Placement3d(position),
1042                    radius,
1043                )?;
1044                m::SurfaceRef::CylindricalSurface(cyl)
1045            }
1046            SurfaceInput::Sphere(frame, radius) => {
1047                let position = self.placement(&frame)?;
1048                let sphere = self.author.add_spherical_surface(
1049                    String::new(),
1050                    m::Axis2Placement3dRef::Axis2Placement3d(position),
1051                    radius,
1052                )?;
1053                m::SurfaceRef::SphericalSurface(sphere)
1054            }
1055            SurfaceInput::Torus(frame, major_radius, minor_radius) => {
1056                let position = self.placement(&frame)?;
1057                let torus = self.author.add_toroidal_surface(
1058                    String::new(),
1059                    m::Axis2Placement3dRef::Axis2Placement3d(position),
1060                    major_radius,
1061                    minor_radius,
1062                )?;
1063                m::SurfaceRef::ToroidalSurface(torus)
1064            }
1065            SurfaceInput::Cone(frame, radius, semi_angle) => {
1066                let position = self.placement(&frame)?;
1067                let cone = self.author.add_conical_surface(
1068                    String::new(),
1069                    m::Axis2Placement3dRef::Axis2Placement3d(position),
1070                    radius,
1071                    semi_angle,
1072                )?;
1073                m::SurfaceRef::ConicalSurface(cone)
1074            }
1075            SurfaceInput::LinearExtrusion(profile, sweep) => {
1076                let swept_curve = self.profile_geometry(&profile)?;
1077                let len = (sweep[0] * sweep[0] + sweep[1] * sweep[1] + sweep[2] * sweep[2]).sqrt();
1078                let dir = if len < 1e-12 {
1079                    [0.0, 0.0, 1.0]
1080                } else {
1081                    [sweep[0] / len, sweep[1] / len, sweep[2] / len]
1082                };
1083                let a = &mut self.author;
1084                let orientation = a.add_direction(String::new(), dir.to_vec())?;
1085                let vector =
1086                    a.add_vector(String::new(), m::DirectionRef::Direction(orientation), len)?;
1087                let surf = a.add_surface_of_linear_extrusion(
1088                    String::new(),
1089                    swept_curve,
1090                    m::VectorRef::Vector(vector),
1091                )?;
1092                m::SurfaceRef::SurfaceOfLinearExtrusion(surf)
1093            }
1094            SurfaceInput::Revolution(profile, origin, axis) => {
1095                let swept_curve = self.profile_geometry(&profile)?;
1096                let a = &mut self.author;
1097                let location = a.add_cartesian_point(String::new(), origin.to_vec())?;
1098                let axis_dir = a.add_direction(String::new(), axis.to_vec())?;
1099                let position = a.add_axis1_placement(
1100                    String::new(),
1101                    m::CartesianPointRef::CartesianPoint(location),
1102                    Some(m::DirectionRef::Direction(axis_dir)),
1103                )?;
1104                let surf = a.add_surface_of_revolution(
1105                    String::new(),
1106                    swept_curve,
1107                    m::Axis1PlacementRef::Axis1Placement(position),
1108                )?;
1109                m::SurfaceRef::SurfaceOfRevolution(surf)
1110            }
1111            SurfaceInput::Nurbs(surface) => self.nurbs_surface_geometry(&surface)?,
1112        })
1113    }
1114
1115    /// Add a face over an analytic surface, bounded by the given loops.
1116    ///
1117    /// # Errors
1118    /// An id that did not come from this builder (another builder's, or out
1119    /// of range) fails validation with [`AuthorError`]; beyond that the
1120    /// wiring is fixed, so an error indicates a bug in the builder itself.
1121    pub fn face(
1122        &mut self,
1123        surface: SurfaceInput,
1124        same_sense: bool,
1125        bounds: Vec<FaceBoundInput>,
1126    ) -> Result<m::AdvancedFaceId, AuthorError> {
1127        let face_geometry = self.surface_geometry(surface)?;
1128        let mut face_bounds = Vec::with_capacity(bounds.len());
1129        for bound in bounds {
1130            let mut edge_list = Vec::with_capacity(bound.edges.len());
1131            for (edge, forward) in bound.edges {
1132                let oe = self.author.add_oriented_edge(
1133                    String::new(),
1134                    m::EdgeRef::EdgeCurve(edge),
1135                    forward,
1136                )?;
1137                edge_list.push(m::OrientedEdgeRef::OrientedEdge(oe));
1138            }
1139            let el = self.author.add_edge_loop(String::new(), edge_list)?;
1140            let fb = if bound.outer {
1141                m::FaceBoundRef::FaceOuterBound(self.author.add_face_outer_bound(
1142                    String::new(),
1143                    m::LoopRef::EdgeLoop(el),
1144                    bound.orientation,
1145                )?)
1146            } else {
1147                m::FaceBoundRef::FaceBound(self.author.add_face_bound(
1148                    String::new(),
1149                    m::LoopRef::EdgeLoop(el),
1150                    bound.orientation,
1151                )?)
1152            };
1153            face_bounds.push(fb);
1154        }
1155        self.author
1156            .add_advanced_face(String::new(), face_bounds, face_geometry, same_sense)
1157    }
1158
1159    /// Close the faces into a shell and add the solid to `part`; the solid
1160    /// lands in the part's shape representation on [`finish`](Self::finish)
1161    /// (as an `ADVANCED_BREP_SHAPE_REPRESENTATION`).
1162    ///
1163    /// # Errors
1164    /// An id that did not come from this builder (another builder's, or out
1165    /// of range) fails validation with [`AuthorError`]; beyond that the
1166    /// wiring is fixed, so an error indicates a bug in the builder itself.
1167    pub fn solid(
1168        &mut self,
1169        part: Part,
1170        name: &str,
1171        faces: Vec<m::AdvancedFaceId>,
1172    ) -> Result<m::ManifoldSolidBrepId, AuthorError> {
1173        let shell = self.author.add_closed_shell(
1174            String::new(),
1175            faces.into_iter().map(m::FaceRef::AdvancedFace).collect(),
1176        )?;
1177        let solid = self
1178            .author
1179            .add_manifold_solid_brep(name.to_owned(), m::ClosedShellRef::ClosedShell(shell))?;
1180        self.parts[part.0]
1181            .items
1182            .push(m::RepresentationItemRef::ManifoldSolidBrep(solid));
1183        Ok(solid)
1184    }
1185
1186    /// Close `outer_faces` into the outer shell and each group in `voids` into
1187    /// an internal cavity shell, then add a solid with internal voids
1188    /// (`BREP_WITH_VOIDS`) to `part` — a cavity-bearing body such as a hollow
1189    /// casting. The solid lands in the part's shape representation on
1190    /// [`finish`](Self::finish) (as an `ADVANCED_BREP_SHAPE_REPRESENTATION`,
1191    /// like [`solid`](Self::solid)).
1192    ///
1193    /// `voids` must be non-empty (a solid with no voids is a plain
1194    /// [`solid`](Self::solid)); each inner group is one closed cavity shell.
1195    ///
1196    /// # Void shell orientation (`normals`)
1197    ///
1198    /// STEP orients every bounding shell so its face normals point away from
1199    /// the solid material: outward into free space for the outer shell, and
1200    /// into the empty cavity for a void. Each void group is wrapped in an
1201    /// [`ORIENTED_CLOSED_SHELL`](m::OrientedClosedShell); `normals` tells
1202    /// step-io whether the faces you pass already follow that rule or must be
1203    /// flipped to it.
1204    ///
1205    /// - [`AwayFromMaterial`](VoidShellNormals::AwayFromMaterial): the faces
1206    ///   already point out of the solid (into the cavity) — the same rule your
1207    ///   outer shell follows. Natural, and what most kernels produce when they
1208    ///   emit a cavity as a reversed shell; kept as authored (`.T.`). Use this
1209    ///   if your kernel orients cavities consistently with the outer shell.
1210    ///   When in doubt, this is the right choice.
1211    /// - [`TowardMaterial`](VoidShellNormals::TowardMaterial): the faces point
1212    ///   into the material — you built the cavity like an ordinary
1213    ///   outward-facing box (normals facing outward from the cavity volume,
1214    ///   toward the surrounding solid). step-io reverses them (`.F.`).
1215    ///
1216    /// Quick test: look along a cavity wall's normal. Into the empty hole →
1217    /// `AwayFromMaterial`; into the surrounding solid → `TowardMaterial`.
1218    /// step-io does not evaluate geometry — it trusts this declaration, and the
1219    /// wrong value inverts the void (validators and mass-property sign
1220    /// conventions will reject it).
1221    ///
1222    /// # Errors
1223    /// Empty `voids`, or empty face lists, fail validation with
1224    /// [`AuthorError`] (the strict `CLOSED_SHELL` / `BREP_WITH_VOIDS`
1225    /// cardinality checks). An id that did not come from this builder fails the
1226    /// same way; beyond that the wiring is fixed, so an error indicates a bug
1227    /// in the builder itself.
1228    pub fn solid_with_voids(
1229        &mut self,
1230        part: Part,
1231        name: &str,
1232        outer_faces: Vec<m::AdvancedFaceId>,
1233        voids: Vec<Vec<m::AdvancedFaceId>>,
1234        normals: VoidShellNormals,
1235    ) -> Result<m::BrepWithVoidsId, AuthorError> {
1236        let outer = self.author.add_closed_shell(
1237            String::new(),
1238            outer_faces
1239                .into_iter()
1240                .map(m::FaceRef::AdvancedFace)
1241                .collect(),
1242        )?;
1243        // `.T.` keeps the faces as authored, `.F.` reverses them; see
1244        // `conditional_reverse` in the schema.
1245        let orientation = match normals {
1246            VoidShellNormals::AwayFromMaterial => true, // .T. — as authored (natural)
1247            VoidShellNormals::TowardMaterial => false,  // .F. — topology_reversed
1248        };
1249        let mut void_refs = Vec::with_capacity(voids.len());
1250        for void_faces in voids {
1251            let shell = self.author.add_closed_shell(
1252                String::new(),
1253                void_faces
1254                    .into_iter()
1255                    .map(m::FaceRef::AdvancedFace)
1256                    .collect(),
1257            )?;
1258            let oriented = self.author.add_oriented_closed_shell(
1259                String::new(),
1260                m::ClosedShellRef::ClosedShell(shell),
1261                orientation,
1262            )?;
1263            void_refs.push(m::OrientedClosedShellRef::OrientedClosedShell(oriented));
1264        }
1265        let solid = self.author.add_brep_with_voids(
1266            name.to_owned(),
1267            m::ClosedShellRef::ClosedShell(outer),
1268            void_refs,
1269        )?;
1270        self.parts[part.0]
1271            .items
1272            .push(m::RepresentationItemRef::BrepWithVoids(solid));
1273        Ok(solid)
1274    }
1275
1276    /// Add one part: the full product chain (product → formation →
1277    /// definition → definition shape) plus its origin placement. Shape items
1278    /// collected for the part are bound into its shape representation by
1279    /// [`finish`](Self::finish).
1280    ///
1281    /// # Errors
1282    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1283    /// here is fixed, so an error indicates a bug in the builder itself.
1284    pub fn part(&mut self, name: &str) -> Result<Part, AuthorError> {
1285        self.part_with(name, &PartOptions::default())
1286    }
1287
1288    /// [`part`](Self::part) with metadata overrides: part number, version
1289    /// label, and the description fields.
1290    ///
1291    /// # Errors
1292    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1293    /// here is fixed, so an error indicates a bug in the builder itself.
1294    pub fn part_with(&mut self, name: &str, opts: &PartOptions) -> Result<Part, AuthorError> {
1295        let origin = self.placement(&Frame {
1296            origin: [0.0; 3],
1297            axis: [0.0, 0.0, 1.0],
1298            ref_dir: [1.0, 0.0, 0.0],
1299        })?;
1300        let a = &mut self.author;
1301        let product = a.add_product(
1302            opts.id.clone().unwrap_or_else(|| name.to_owned()),
1303            name.to_owned(),
1304            opts.description.clone(),
1305            vec![m::ProductContextRef::ProductContext(self.product_context)],
1306        )?;
1307        let formation = a.add_product_definition_formation(
1308            opts.version.clone().unwrap_or_default(),
1309            opts.version_description.clone(),
1310            m::ProductRef::Product(product),
1311        )?;
1312        let definition = a.add_product_definition(
1313            "design".to_owned(),
1314            opts.definition_description.clone(),
1315            m::ProductDefinitionFormationRef::ProductDefinitionFormation(formation),
1316            m::ProductDefinitionContextRef::ProductDefinitionContext(self.pd_context),
1317        )?;
1318        let shape = a.add_product_definition_shape(
1319            String::new(),
1320            None,
1321            m::CharacterizedDefinitionRef::ProductDefinition(definition),
1322        )?;
1323
1324        self.parts.push(PendingPart {
1325            product,
1326            formation,
1327            definition,
1328            shape,
1329            origin,
1330            items: Vec::new(),
1331            meshes: Vec::new(),
1332        });
1333        Ok(Part(self.parts.len() - 1))
1334    }
1335
1336    /// Attach a display mesh to `part`, optionally linked to the b-rep solid
1337    /// it tessellates (the link is what `MeshGroup::solid()` resolves on the
1338    /// read side). Triangle indices are 0-based into `points`; the builder
1339    /// converts to STEP's 1-based strip encoding. The mesh lands in its own
1340    /// `TESSELLATED_SHAPE_REPRESENTATION` on [`finish`](Self::finish).
1341    ///
1342    /// # Errors
1343    /// An id that did not come from this builder (another builder's, or out
1344    /// of range) fails validation with [`AuthorError`]; beyond that the
1345    /// wiring is fixed, so an error indicates a bug in the builder itself.
1346    pub fn mesh(
1347        &mut self,
1348        part: Part,
1349        name: &str,
1350        input: &MeshInput,
1351        of_solid: Option<SolidRef>,
1352    ) -> Result<m::TessellatedSolidId, AuthorError> {
1353        let a = &mut self.author;
1354        let npoints = i64::try_from(input.points.len()).unwrap_or(i64::MAX);
1355        let coordinates = a.add_coordinates_list(
1356            String::new(),
1357            npoints,
1358            input.points.iter().map(|p| p.to_vec()).collect(),
1359        )?;
1360        let normals: Vec<Vec<f64>> = match &input.normals {
1361            MeshNormalsInput::None => Vec::new(),
1362            MeshNormalsInput::Uniform(n) => vec![n.to_vec()],
1363            MeshNormalsInput::PerVertex(rows) => rows.iter().map(|n| n.to_vec()).collect(),
1364        };
1365        // One 3-index strip per triangle, 1-based. The read side's strip
1366        // decode swaps the last two indices of the first window, so encode
1367        // (p, q, r) as [p, r, q] to get the original winding back.
1368        let to_ix = |v: usize| i64::try_from(v + 1).unwrap_or(i64::MAX);
1369        let triangle_strips: Vec<Vec<i64>> = input
1370            .triangles
1371            .iter()
1372            .map(|&[p, q, r]| vec![to_ix(p), to_ix(r), to_ix(q)])
1373            .collect();
1374        let face = a.add_complex_triangulated_face(
1375            name.to_owned(),
1376            m::CoordinatesListRef::CoordinatesList(coordinates),
1377            npoints,
1378            normals,
1379            None,
1380            Vec::new(),
1381            triangle_strips,
1382            Vec::new(),
1383        )?;
1384        let solid = a.add_tessellated_solid(
1385            name.to_owned(),
1386            vec![m::TessellatedStructuredItemRef::ComplexTriangulatedFace(
1387                face,
1388            )],
1389            of_solid.map(|s| match s {
1390                SolidRef::Solid(id) => m::ManifoldSolidBrepRef::ManifoldSolidBrep(id),
1391                SolidRef::VoidSolid(id) => m::ManifoldSolidBrepRef::BrepWithVoids(id),
1392            }),
1393        )?;
1394        self.parts[part.0].meshes.push(solid);
1395        Ok(solid)
1396    }
1397
1398    /// A person at an organization — reusable across
1399    /// [`contributor`](Self::contributor) and approver roles.
1400    ///
1401    /// # Errors
1402    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1403    /// here is fixed, so an error indicates a bug in the builder itself.
1404    pub fn person_and_org(
1405        &mut self,
1406        p: &PersonOrg,
1407    ) -> Result<m::PersonAndOrganizationId, AuthorError> {
1408        let a = &mut self.author;
1409        let person = a.add_person(
1410            p.person_id.clone(),
1411            p.last_name.clone(),
1412            p.first_name.clone(),
1413            None,
1414            None,
1415            None,
1416        )?;
1417        let organization = a.add_organization(None, p.organization.clone(), None)?;
1418        a.add_person_and_organization(
1419            m::PersonRef::Person(person),
1420            m::OrganizationRef::Organization(organization),
1421        )
1422    }
1423
1424    /// Record `who` as a contributor to the part's version under the given
1425    /// role (customary roles: `"creator"`, `"design_owner"`, `"checker"`).
1426    ///
1427    /// # Errors
1428    /// An id that did not come from this builder (another builder's, or out
1429    /// of range) fails validation with [`AuthorError`]; beyond that the
1430    /// wiring is fixed, so an error indicates a bug in the builder itself.
1431    pub fn contributor(
1432        &mut self,
1433        part: Part,
1434        role: &str,
1435        who: m::PersonAndOrganizationId,
1436    ) -> Result<(), AuthorError> {
1437        let a = &mut self.author;
1438        let role = a.add_person_and_organization_role(role.to_owned())?;
1439        a.add_applied_person_and_organization_assignment(
1440            m::PersonAndOrganizationRef::PersonAndOrganization(who),
1441            m::PersonAndOrganizationRoleRef::PersonAndOrganizationRole(role),
1442            vec![m::PersonAndOrganizationItemRef::ProductDefinitionFormation(
1443                self.parts[part.0].formation,
1444            )],
1445        )?;
1446        Ok(())
1447    }
1448
1449    /// Attach an approval to the part's version: a status word, a level,
1450    /// any approvers with their roles, and an optional approval date.
1451    ///
1452    /// # Errors
1453    /// An id that did not come from this builder (another builder's, or out
1454    /// of range) fails validation with [`AuthorError`]; beyond that the
1455    /// wiring is fixed, so an error indicates a bug in the builder itself.
1456    pub fn approve(
1457        &mut self,
1458        part: Part,
1459        input: &ApprovalInput,
1460    ) -> Result<m::ApprovalId, AuthorError> {
1461        let a = &mut self.author;
1462        let status = a.add_approval_status(input.status.clone())?;
1463        let approval = a.add_approval(
1464            m::ApprovalStatusRef::ApprovalStatus(status),
1465            input.level.clone(),
1466        )?;
1467        a.add_applied_approval_assignment(
1468            m::ApprovalRef::Approval(approval),
1469            vec![m::ApprovalItemRef::ProductDefinitionFormation(
1470                self.parts[part.0].formation,
1471            )],
1472        )?;
1473        for (who, role) in &input.approvers {
1474            let role = a.add_approval_role(role.clone())?;
1475            a.add_approval_person_organization(
1476                m::PersonOrganizationSelectRef::PersonAndOrganization(*who),
1477                m::ApprovalRef::Approval(approval),
1478                m::ApprovalRoleRef::ApprovalRole(role),
1479            )?;
1480        }
1481        if let Some(date) = input.date {
1482            let calendar = a.add_calendar_date(date.year, date.day, date.month)?;
1483            let zone = a.add_coordinated_universal_time_offset(0, None, m::AheadOrBehind::Exact)?;
1484            let time = a.add_local_time(
1485                date.hour,
1486                date.minute,
1487                None,
1488                m::CoordinatedUniversalTimeOffsetRef::CoordinatedUniversalTimeOffset(zone),
1489            )?;
1490            let date_time = a.add_date_and_time(
1491                m::DateRef::CalendarDate(calendar),
1492                m::LocalTimeRef::LocalTime(time),
1493            )?;
1494            a.add_approval_date_time(
1495                m::DateTimeSelectRef::DateAndTime(date_time),
1496                m::ApprovalRef::Approval(approval),
1497            )?;
1498        }
1499        Ok(approval)
1500    }
1501
1502    /// Attach an external document reference to the part's version.
1503    ///
1504    /// # Errors
1505    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1506    /// here is fixed, so an error indicates a bug in the builder itself.
1507    pub fn document(&mut self, part: Part, d: &DocumentInput) -> Result<(), AuthorError> {
1508        let a = &mut self.author;
1509        let kind = a.add_document_type(d.kind.clone())?;
1510        let doc = a.add_document(
1511            d.id.clone(),
1512            d.name.clone(),
1513            d.description.clone(),
1514            m::DocumentTypeRef::DocumentType(kind),
1515        )?;
1516        a.add_applied_document_reference(
1517            m::DocumentRef::Document(doc),
1518            String::new(),
1519            vec![m::DocumentReferenceItemRef::ProductDefinitionFormation(
1520                self.parts[part.0].formation,
1521            )],
1522        )?;
1523        Ok(())
1524    }
1525
1526    /// Attach a security classification to the part's version.
1527    ///
1528    /// # Errors
1529    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1530    /// here is fixed, so an error indicates a bug in the builder itself.
1531    pub fn security(
1532        &mut self,
1533        part: Part,
1534        name: &str,
1535        purpose: &str,
1536        level: &str,
1537    ) -> Result<(), AuthorError> {
1538        let a = &mut self.author;
1539        let level = a.add_security_classification_level(level.to_owned())?;
1540        let classification = a.add_security_classification(
1541            name.to_owned(),
1542            purpose.to_owned(),
1543            m::SecurityClassificationLevelRef::SecurityClassificationLevel(level),
1544        )?;
1545        a.add_applied_security_classification_assignment(
1546            m::SecurityClassificationRef::SecurityClassification(classification),
1547            vec![
1548                m::SecurityClassificationItemRef::ProductDefinitionFormation(
1549                    self.parts[part.0].formation,
1550                ),
1551            ],
1552        )?;
1553        Ok(())
1554    }
1555
1556    /// Place `child` inside `parent` at the given frame — one assembly
1557    /// occurrence (the same child may be placed any number of times; each
1558    /// call is one instance). The usage occurrence is created immediately;
1559    /// its shape wiring is bound in [`finish`](Self::finish).
1560    ///
1561    /// # Errors
1562    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1563    /// here is fixed, so an error indicates a bug in the builder itself.
1564    pub fn place(
1565        &mut self,
1566        parent: Part,
1567        child: Part,
1568        at: Frame,
1569    ) -> Result<m::NextAssemblyUsageOccurrenceId, AuthorError> {
1570        let nauo = self.author.add_next_assembly_usage_occurrence(
1571            format!("{}", self.placements.len() + 1),
1572            String::new(),
1573            None,
1574            m::ProductDefinitionOrReferenceRef::ProductDefinition(self.parts[parent.0].definition),
1575            m::ProductDefinitionOrReferenceRef::ProductDefinition(self.parts[child.0].definition),
1576            None,
1577        )?;
1578        let to = self.placement(&at)?;
1579        self.placements.push(PendingPlacement {
1580            nauo,
1581            parent: parent.0,
1582            child: child.0,
1583            to,
1584        });
1585        Ok(nauo)
1586    }
1587
1588    /// Materialize each part's shape representation (its origin placement
1589    /// plus the collected items) with its shape-definition link, add the
1590    /// customary product category, and emit the Part 21 text.
1591    ///
1592    /// # Errors
1593    /// Propagates [`AuthorError`] from the strict constructors; the wiring
1594    /// here is fixed, so an error indicates a bug in the builder itself.
1595    pub fn finish(mut self) -> Result<String, AuthorError> {
1596        let a = &mut self.author;
1597        // Pass 1: one shape representation per part (a part carrying a solid
1598        // is emitted as the customary ADVANCED_BREP_SHAPE_REPRESENTATION),
1599        // kept by part index for the placement wiring below.
1600        let mut part_reps: Vec<m::RepresentationOrRepresentationReferenceRef> =
1601            Vec::with_capacity(self.parts.len());
1602        for part in &self.parts {
1603            let mut items = vec![m::RepresentationItemRef::Axis2Placement3d(part.origin)];
1604            items.extend(part.items.iter().cloned());
1605            let has_solid = part.items.iter().any(|i| {
1606                matches!(
1607                    i,
1608                    m::RepresentationItemRef::ManifoldSolidBrep(_)
1609                        | m::RepresentationItemRef::BrepWithVoids(_)
1610                )
1611            });
1612            let (rep, rep_or_ref) = if has_solid {
1613                let id = a.add_advanced_brep_shape_representation(
1614                    String::new(),
1615                    items,
1616                    m::RepresentationContextRef::Complex(self.ctx),
1617                )?;
1618                (
1619                    m::RepresentationRef::AdvancedBrepShapeRepresentation(id),
1620                    m::RepresentationOrRepresentationReferenceRef::AdvancedBrepShapeRepresentation(
1621                        id,
1622                    ),
1623                )
1624            } else {
1625                let id = a.add_shape_representation(
1626                    String::new(),
1627                    items,
1628                    m::RepresentationContextRef::Complex(self.ctx),
1629                )?;
1630                (
1631                    m::RepresentationRef::ShapeRepresentation(id),
1632                    m::RepresentationOrRepresentationReferenceRef::ShapeRepresentation(id),
1633                )
1634            };
1635            a.add_shape_definition_representation(
1636                m::RepresentedDefinitionRef::ProductDefinitionShape(part.shape),
1637                rep,
1638            )?;
1639            part_reps.push(rep_or_ref);
1640
1641            // A part with display meshes gets its own tessellated
1642            // representation alongside the shape one (multi-SDR per shape).
1643            if !part.meshes.is_empty() {
1644                let tsr = a.add_tessellated_shape_representation(
1645                    String::new(),
1646                    part.meshes
1647                        .iter()
1648                        .map(|&t| m::RepresentationItemRef::TessellatedSolid(t))
1649                        .collect(),
1650                    m::RepresentationContextRef::Complex(self.ctx),
1651                )?;
1652                a.add_shape_definition_representation(
1653                    m::RepresentedDefinitionRef::ProductDefinitionShape(part.shape),
1654                    m::RepresentationRef::TessellatedShapeRepresentation(tsr),
1655                )?;
1656            }
1657        }
1658        // Pass 2: assembly placements.
1659        bind_placements(a, &self.parts, &self.placements, &part_reps)?;
1660        if !self.parts.is_empty() {
1661            a.add_product_related_product_category(
1662                "part".to_owned(),
1663                None,
1664                self.parts
1665                    .iter()
1666                    .map(|p| m::ProductRef::Product(p.product))
1667                    .collect(),
1668            )?;
1669        }
1670        // Hidden items (individual styled items and whole layers) collect
1671        // into one INVISIBILITY.
1672        if !self.hidden.is_empty() {
1673            a.add_invisibility(std::mem::take(&mut self.hidden))?;
1674        }
1675        // Styles are anchored in the customary presentation representation.
1676        if !self.styles.is_empty() {
1677            a.add_mechanical_design_geometric_presentation_representation(
1678                String::new(),
1679                self.styles
1680                    .iter()
1681                    .map(|&s| m::RepresentationItemRef::StyledItem(s))
1682                    .collect(),
1683                m::RepresentationContextRef::Complex(self.ctx),
1684            )?;
1685        }
1686        let h = &self.header;
1687        let file_header = FileHeader {
1688            description: h.description.clone().unwrap_or_default(),
1689            file_name: h.file_name.clone().unwrap_or_default(),
1690            time_stamp: h.timestamp.clone().unwrap_or_else(now_iso_utc),
1691            authors: h.authors.clone(),
1692            organizations: h.organizations.clone(),
1693            preprocessor_version: concat!("step-io ", env!("CARGO_PKG_VERSION")).to_owned(),
1694            originating_system: h.originating_system.clone().unwrap_or_default(),
1695            authorisation: h.authorisation.clone().unwrap_or_default(),
1696            // The authoring layer stamps the AP242 schema identity in
1697            // `finish_with_header`; whatever is set here is overwritten.
1698            schema: crate::parser::SchemaId::default(),
1699        };
1700        Ok(self.author.finish_with_header(&file_header))
1701    }
1702}
1703
1704/// Assembly-placement wiring, deferred to `finish`: each occurrence gets its
1705/// placement shape (PDS over the NAUO) bound to an item-defined
1706/// transformation (item 1 = parent origin/from, item 2 = child placement/to)
1707/// through the customary relationship complex (`rep_1` = child, `rep_2` =
1708/// parent).
1709fn bind_placements(
1710    a: &mut Ap242Author,
1711    parts: &[PendingPart],
1712    placements: &[PendingPlacement],
1713    part_reps: &[m::RepresentationOrRepresentationReferenceRef],
1714) -> Result<(), AuthorError> {
1715    for placement in placements {
1716        let pds = a.add_product_definition_shape(
1717            "Placement".to_owned(),
1718            Some("Placement of an item".to_owned()),
1719            m::CharacterizedDefinitionRef::NextAssemblyUsageOccurrence(placement.nauo),
1720        )?;
1721        let idt = a.add_item_defined_transformation(
1722            String::new(),
1723            None,
1724            m::RepresentationItemRef::Axis2Placement3d(parts[placement.parent].origin),
1725            m::RepresentationItemRef::Axis2Placement3d(placement.to),
1726        )?;
1727        let rrwt = a.add_complex(vec![
1728            m::UnitPart::RepresentationRelationship {
1729                name: String::new(),
1730                description: None,
1731                rep_1: part_reps[placement.child].clone(),
1732                rep_2: part_reps[placement.parent].clone(),
1733            },
1734            m::UnitPart::RepresentationRelationshipWithTransformation {
1735                transformation_operator: m::TransformationRef::ItemDefinedTransformation(idt),
1736            },
1737            m::UnitPart::ShapeRepresentationRelationship,
1738        ])?;
1739        a.add_context_dependent_shape_representation(
1740            m::ShapeRepresentationRelationshipRef::Complex(rrwt),
1741            m::ProductDefinitionShapeRef::ProductDefinitionShape(pds),
1742        )?;
1743    }
1744    Ok(())
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749    use super::iso_utc_from_unix;
1750
1751    #[test]
1752    fn iso_utc_from_unix_known_values() {
1753        assert_eq!(iso_utc_from_unix(0), "1970-01-01T00:00:00Z");
1754        // Leap-day boundary: 2024-02-29 23:59:59 UTC.
1755        assert_eq!(iso_utc_from_unix(1_709_251_199), "2024-02-29T23:59:59Z");
1756        // The day after: 2024-03-01 00:00:00 UTC.
1757        assert_eq!(iso_utc_from_unix(1_709_251_200), "2024-03-01T00:00:00Z");
1758    }
1759}