Skip to main content

ogeom_io/step/
write.rs

1//! Writing STEP: the document's products, assemblies, colours, B-rep and
2//! construction geometry.
3//!
4//! The mirror of the reader, and deliberately written against the same
5//! vocabulary: every entity this writer emits is one the reader parses, so
6//! writing what was read and reading it back is the honest round-trip test.
7//! AP214's schema name goes in the header; the entities used are the common
8//! AP203/AP214/AP242 core.
9//!
10//! Geometry is written in world coordinates: every face, edge and vertex is
11//! transformed through its occurrence's own placement chain before it is
12//! serialized, and shared nodes are deduplicated *per placement*: a prism's
13//! bottom and top edge are one node at two locations, and the file needs
14//! both. Surfaces the format has no analytic name for (extrusions,
15//! revolutions) go out as their exact rational B-spline patches, so nothing
16//! is fitted on the way out.
17//!
18//! An edge's pcurves go out with it, as the `PCURVE`s of a `SURFACE_CURVE`
19//! (a `SEAM_CURVE` where one face's surface meets itself along it), wherever
20//! the file's parameterizations are this kernel's own: the curve and the
21//! surface written analytically or as the same spline, in right-handed
22//! frames, under a placement that neither scales nor mirrors, and the pcurve
23//! a line, a forward conic or a spline, restated over the curve's parameter
24//! where the edge keeps it over a range of its own. Elsewhere the edge
25//! carries its curve alone and a reader derives the pcurves.
26
27use ogeom_core::FastMap;
28use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
29use ogeom_doc::{Document, ProductId, ProductKind};
30use ogeom_geom::Curve2d as _;
31use ogeom_geom::Curve3d as _;
32use ogeom_geom::Surface as _;
33use ogeom_geom::Transformable as _;
34use ogeom_geom::{Curve, PlanarCurve, SurfaceGeometry};
35use ogeom_math::{Frame, Handedness, Point, Point2, Transform, Vector};
36use ogeom_topo::{EdgeRepr, Filter, Model, NodeData, Shape, ShapeType, SurfaceId, explore};
37use std::collections::BTreeMap;
38use std::fmt::Write as _;
39
40/// Write a document as a STEP exchange file.
41///
42/// Products become `PRODUCT` trees; parts carry their solids as
43/// `MANIFOLD_SOLID_BREP`s in an `ADVANCED_BREP_SHAPE_REPRESENTATION`, and
44/// their sheets (shells no solid owns, faces no shell owns) as
45/// `SHELL_BASED_SURFACE_MODEL`s in a `MANIFOLD_SURFACE_SHAPE_REPRESENTATION`;
46/// a part holding both carries both representations, related to each other.
47/// A product's construction geometry goes in a geometrically bounded
48/// wireframe representation (a surface among it: a geometrically bounded
49/// surface one) related to the product's, each item named; a part holding
50/// construction geometry and no body is that representation alone.
51/// Assemblies become usage occurrences with their placements; colours
52/// become styled items over the written solids, sheets and faces (a fill
53/// style) and edges (a curve style). PMI anchors to the written faces and
54/// edges through shape aspects.
55///
56/// # Errors
57///
58/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
59/// shape's structure cannot be expressed: a non-rigid instance placement, a
60/// solid with no shell, a part holding wireframe (a wire, edge or vertex
61/// outside every face) or nothing at all, a left-handed construction axis.
62pub fn write_step(document: &Document, tol: Tolerances) -> OgeomResult<String> {
63    NON_FINITE.with(|seen| seen.set(false));
64    let mut writer = Writer {
65        model: document.model(),
66        entities: Vec::new(),
67        points: FastMap::default(),
68        directions: FastMap::default(),
69        vertices: FastMap::default(),
70        edges: FastMap::default(),
71        written_nodes: Vec::new(),
72        written_edges: Vec::new(),
73        curve_font: None,
74        surface_curves: BTreeMap::new(),
75        parametric_context: None,
76        tol,
77    };
78
79    let app = writer.entity("APPLICATION_CONTEXT('automotive design')".into());
80    let _proto = writer.entity(format!(
81        "APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2010,#{app})"
82    ));
83    let lu = writer.entity("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))".into());
84    let au = writer.entity("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))".into());
85    let su = writer.entity("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())".into());
86    let unc = writer.entity(format!(
87        "UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.0E-06),#{lu},'distance_accuracy_value','')"
88    ));
89    let gctx = writer.entity(format!(
90        "(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{lu},#{au},#{su}))REPRESENTATION_CONTEXT('Context','3D'))"
91    ));
92    let pctx = writer.entity(format!("PRODUCT_CONTEXT('',#{app},'mechanical')"));
93    let pdctx = writer.entity(format!(
94        "PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')"
95    ));
96
97    // Products, in document order; every product gets its definition and its
98    // shape representation before the assembly edges tie them together.
99    let mut pd_of: FastMap<ProductId, u64> = FastMap::default();
100    let mut sr_of: FastMap<ProductId, u64> = FastMap::default();
101    let mut anchor_pds: Option<(u64, u64)> = None;
102    let mut owner: FastMap<ogeom_topo::TShapeId, (u64, u64)> = FastMap::default();
103    for (id, product) in document.products() {
104        let name = escape(&product.name);
105        let p = writer.entity(format!("PRODUCT('{name}','{name}','',(#{pctx}))"));
106        let formation = writer.entity(format!("PRODUCT_DEFINITION_FORMATION('','',#{p})"));
107        let pd = writer.entity(format!(
108            "PRODUCT_DEFINITION('design','',#{formation},#{pdctx})"
109        ));
110        pd_of.insert(id, pd);
111
112        let world = writer.frame(&Frame::WORLD);
113        let construction: Vec<&ogeom_doc::Construction> = document.construction_of(id).collect();
114        let written_before = (writer.written_nodes.len(), writer.written_edges.len());
115        let sr = match &product.kind {
116            ProductKind::Part { shape } => {
117                // A part carrying construction geometry may hold no body.
118                let bodies = if construction.is_empty() {
119                    crate::bodies::bodies_of(writer.model, shape, "STEP", false)?
120                } else {
121                    crate::bodies::bodies_within(writer.model, shape, "STEP", false)?
122                };
123                let mut solids = vec![world];
124                for solid in &bodies.solids {
125                    solids.push(writer.solid(solid)?);
126                }
127                let mut sheets = vec![world];
128                for shell in &bodies.shells {
129                    sheets.push(writer.surface_model(shell, false)?);
130                }
131                for face in &bodies.faces {
132                    sheets.push(writer.surface_model(face, true)?);
133                }
134                // Solids go in a B-rep representation and sheets in a
135                // surface one; a part with both names the B-rep and ties
136                // the surface representation to it.
137                let brep = (solids.len() > 1).then(|| {
138                    let list = reference_list(&solids);
139                    writer.entity(format!(
140                        "ADVANCED_BREP_SHAPE_REPRESENTATION('{name}',({list}),#{gctx})"
141                    ))
142                });
143                let surface = (sheets.len() > 1).then(|| {
144                    let list = reference_list(&sheets);
145                    writer.entity(format!(
146                        "MANIFOLD_SURFACE_SHAPE_REPRESENTATION('{name}',({list}),#{gctx})"
147                    ))
148                });
149                let geometric = writer.construction(&construction, &name, gctx)?;
150                let sr = match (brep, surface) {
151                    (Some(brep), Some(surface)) => {
152                        writer.entity(format!(
153                            "SHAPE_REPRESENTATION_RELATIONSHIP('','',#{brep},#{surface})"
154                        ));
155                        brep
156                    }
157                    (Some(only), None) | (None, Some(only)) => only,
158                    (None, None) => match geometric {
159                        Some(only) => only,
160                        None => ogeom_bail!(
161                            Construction,
162                            "a part's shape holds no solid, shell or face to write as STEP"
163                        ),
164                    },
165                };
166                if let Some(geometric) = geometric.filter(|&g| g != sr) {
167                    writer.entity(format!(
168                        "SHAPE_REPRESENTATION_RELATIONSHIP('','',#{sr},#{geometric})"
169                    ));
170                }
171                sr
172            }
173            ProductKind::Assembly { .. } => {
174                let sr =
175                    writer.entity(format!("SHAPE_REPRESENTATION('{name}',(#{world}),#{gctx})"));
176                if let Some(geometric) = writer.construction(&construction, &name, gctx)? {
177                    writer.entity(format!(
178                        "SHAPE_REPRESENTATION_RELATIONSHIP('','',#{sr},#{geometric})"
179                    ));
180                }
181                sr
182            }
183        };
184        sr_of.insert(id, sr);
185        let pds = writer.entity(format!("PRODUCT_DEFINITION_SHAPE('','',#{pd})"));
186        writer.entity(format!("SHAPE_DEFINITION_REPRESENTATION(#{pds},#{sr})"));
187        if matches!(product.kind, ProductKind::Part { .. }) {
188            if anchor_pds.is_none() {
189                anchor_pds = Some((pds, sr));
190            }
191            // Each piece of this part's topology is the part's to anchor
192            // PMI on, the first part that wrote it keeping it.
193            let (nodes, edges) = written_before;
194            for &(node, _) in writer.written_nodes[nodes..]
195                .iter()
196                .chain(&writer.written_edges[edges..])
197            {
198                owner.entry(node).or_insert((pds, sr));
199            }
200        }
201    }
202
203    writer.surface_curves()?;
204
205    // Assembly edges: one usage occurrence per instance, its placement said
206    // through the transformation between the parent's world frame and the
207    // child's placement frame.
208    let mut usage = 0_usize;
209    for (id, product) in document.products() {
210        let ProductKind::Assembly { children } = &product.kind else {
211            continue;
212        };
213        for instance in children {
214            usage += 1;
215            let designator = instance
216                .name
217                .clone()
218                .unwrap_or_else(|| format!("occurrence-{usage}"));
219            let designator = escape(&designator);
220            let (parent_pd, child_pd) = (pd_of[&id], pd_of[&instance.product]);
221            let (parent_sr, child_sr) = (sr_of[&id], sr_of[&instance.product]);
222            let nauo = writer.entity(format!(
223                "NEXT_ASSEMBLY_USAGE_OCCURRENCE('{designator}','{designator}','',#{parent_pd},#{child_pd},$)"
224            ));
225            // The location resolved through the model's own datum store: a
226            // placed dummy shape shares the resolution path every traversal
227            // uses.
228            let at = location_transform(&instance.location, document.model())?;
229            let placed = writer.placement_frame(&at)?;
230            let world = writer.frame(&Frame::WORLD);
231            let idt = writer.entity(format!(
232                "ITEM_DEFINED_TRANSFORMATION('','',#{world},#{placed})"
233            ));
234            let rr = writer.entity(format!(
235                "(REPRESENTATION_RELATIONSHIP('','',#{child_sr},#{parent_sr})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{idt})SHAPE_REPRESENTATION_RELATIONSHIP())"
236            ));
237            let pds = writer.entity(format!("PRODUCT_DEFINITION_SHAPE('','',#{nauo})"));
238            writer.entity(format!(
239                "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{rr},#{pds})"
240            ));
241        }
242    }
243
244    // Colours: a styled item over every written entity whose node the
245    // document colours, plus product colours carried by their solids. An
246    // edge's colour is a curve style on each of its EDGE_CURVEs.
247    let mut styled = Vec::new();
248    let node_colours: FastMap<_, _> = document.colours().collect();
249    // The written entities by node, each list in the order they were
250    // written: a coloured product finds its own without walking them all.
251    let written = std::mem::take(&mut writer.written_nodes);
252    let mut by_node: FastMap<ogeom_topo::TShapeId, Vec<u64>> = FastMap::default();
253    for &(node, step_id) in &written {
254        by_node.entry(node).or_default().push(step_id);
255    }
256    for &(node, step_id) in &written {
257        if let Some(colour) = node_colours.get(&node) {
258            styled.push(writer.styled_item(step_id, *colour));
259        }
260    }
261    for (_, product) in document.products() {
262        let Some(colour) = product.colour else {
263            continue;
264        };
265        let ProductKind::Part { shape } = &product.kind else {
266            continue;
267        };
268        if node_colours.contains_key(&shape.node()) {
269            continue;
270        }
271        for &step_id in by_node.get(&shape.node()).map_or(&[][..], Vec::as_slice) {
272            styled.push(writer.styled_item(step_id, colour));
273        }
274    }
275    writer.written_nodes = written;
276    let edges = std::mem::take(&mut writer.written_edges);
277    for &(node, step_id) in &edges {
278        if let Some(colour) = node_colours.get(&node) {
279            styled.push(writer.curve_styled_item(step_id, *colour));
280        }
281    }
282    writer.written_edges = edges;
283    if !styled.is_empty() {
284        let list = styled
285            .iter()
286            .map(|i| format!("#{i}"))
287            .collect::<Vec<_>>()
288            .join(",");
289        writer.entity(format!(
290            "MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',({list}),#{gctx})"
291        ));
292    }
293
294    // Semantic PMI: datums first, so tolerances can reference their letters.
295    let pmi = document.pmi();
296    if !pmi.is_empty() {
297        let Some((pds, absr)) = anchor_pds else {
298            ogeom_bail!(
299                Construction,
300                "PMI needs at least one part to anchor its aspects to"
301            );
302        };
303        writer.pmi(pmi, document.views(), (pds, absr), &owner, lu, au, gctx)?;
304    }
305
306    let mut out = String::new();
307    out.push_str("ISO-10303-21;\nHEADER;\n");
308    out.push_str("FILE_DESCRIPTION(('written by ogeom'),'2;1');\n");
309    out.push_str("FILE_NAME('','',('ogeom'),('ogeom'),'ogeom','ogeom','');\n");
310    out.push_str("FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\n");
311    out.push_str("ENDSEC;\nDATA;\n");
312    for (i, entity) in writer.entities.iter().enumerate() {
313        let _ = writeln!(out, "#{}={entity};", i + 1);
314    }
315    out.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
316    if NON_FINITE.with(std::cell::Cell::get) {
317        ogeom_bail!(
318            Construction,
319            "the model holds a number that is not finite, which a STEP file cannot state"
320        );
321    }
322    Ok(out)
323}
324
325std::thread_local! {
326    /// Set when [`real`] meets a value Part 21 has no spelling for, so the
327    /// write refuses rather than emit a file no reader accepts.
328    static NON_FINITE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
329}
330
331/// The state of one write: the entity buffer and the per-placement caches.
332struct Writer<'a> {
333    model: &'a Model,
334    entities: Vec<String>,
335    points: FastMap<[u64; 3], u64>,
336    directions: FastMap<[u64; 3], u64>,
337    /// Vertex occurrences by node and world position bits.
338    vertices: FastMap<(ogeom_topo::TShapeId, [u64; 3]), u64>,
339    /// Edge occurrences by node and placement bits.
340    edges: FastMap<(ogeom_topo::TShapeId, [u64; 3]), u64>,
341    /// Every solid and face written, with its entity id: the hooks colours
342    /// attach to.
343    written_nodes: Vec<(ogeom_topo::TShapeId, u64)>,
344    /// Every `EDGE_CURVE` written, with its edge node: one per placement
345    /// of the edge.
346    written_edges: Vec<(ogeom_topo::TShapeId, u64)>,
347    /// The one curve font every edge style shares, once written.
348    curve_font: Option<u64>,
349    /// Every `EDGE_CURVE` written, by its entity id, with the pcurves its
350    /// faces gave it; rewritten over a surface curve once all are known.
351    surface_curves: BTreeMap<u64, EdgeCurve>,
352    /// The parameter-space context every pcurve's representation shares.
353    parametric_context: Option<u64>,
354    tol: Tolerances,
355}
356
357/// An `EDGE_CURVE` as written, and the pcurves its faces hold on it.
358struct EdgeCurve {
359    from: u64,
360    to: u64,
361    curve: u64,
362    /// The edge's range on its curve.
363    range: (f64, f64),
364    /// Whether the curve went out in this kernel's own parameterization,
365    /// so a pcurve over the same range means the same points to a reader.
366    exact: bool,
367    /// Per face surface entity, its pcurves (one, or a seam's two) and
368    /// how their parameter follows the curve's.
369    pcurves: Vec<(u64, Vec<PlanarCurve>, Pace)>,
370}
371
372/// How a pcurve's parameter follows its edge's curve's: `shift + scale * t`
373/// at the curve's `t`. An edge may state its pcurve over a range of its
374/// own, the two related this way.
375#[derive(Debug, Clone, Copy, PartialEq)]
376struct Pace {
377    scale: f64,
378    shift: f64,
379}
380
381impl Pace {
382    const SAME: Self = Self {
383        scale: 1.0,
384        shift: 0.0,
385    };
386
387    /// The pace taking the curve's range onto the pcurve's, the same one
388    /// where the two agree.
389    fn between(curve: (f64, f64), pcurve: (f64, f64), slack: f64) -> Option<Self> {
390        if (curve.0 - pcurve.0).abs() <= slack && (curve.1 - pcurve.1).abs() <= slack {
391            return Some(Self::SAME);
392        }
393        let scale = (pcurve.1 - pcurve.0) / (curve.1 - curve.0);
394        let shift = scale.mul_add(-curve.0, pcurve.0);
395        (scale.is_finite() && shift.is_finite() && scale > 0.0).then_some(Self { scale, shift })
396    }
397}
398
399impl Writer<'_> {
400    fn entity(&mut self, text: String) -> u64 {
401        self.entities.push(text);
402        self.entities.len() as u64
403    }
404
405    fn point(&mut self, p: Point) -> u64 {
406        let key = [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()];
407        if let Some(&id) = self.points.get(&key) {
408            return id;
409        }
410        let id = self.entity(format!(
411            "CARTESIAN_POINT('',({},{},{}))",
412            real(p.x),
413            real(p.y),
414            real(p.z)
415        ));
416        self.points.insert(key, id);
417        id
418    }
419
420    fn direction(&mut self, v: Vector) -> u64 {
421        let key = [v.x.to_bits(), v.y.to_bits(), v.z.to_bits()];
422        if let Some(&id) = self.directions.get(&key) {
423            return id;
424        }
425        let id = self.entity(format!(
426            "DIRECTION('',({},{},{}))",
427            real(v.x),
428            real(v.y),
429            real(v.z)
430        ));
431        self.directions.insert(key, id);
432        id
433    }
434
435    fn frame(&mut self, frame: &Frame) -> u64 {
436        let origin = self.point(frame.origin());
437        let z = self.direction(frame.z().vector());
438        let x = self.direction(frame.x().vector());
439        self.entity(format!("AXIS2_PLACEMENT_3D('',#{origin},#{z},#{x})"))
440    }
441
442    /// A conic's frame: its centre, its `x`, and the axis `x × y`. A conic
443    /// is its centre and its `x` and `y` axes, and a reader takes `y` as
444    /// `z × x`; a frame a mirror left-handed would hand it `-y` and run the
445    /// curve the other way round.
446    fn conic_frame(&mut self, frame: &Frame) -> u64 {
447        let origin = self.point(frame.origin());
448        let z = self.direction(frame.x().vector().cross(frame.y().vector()));
449        let x = self.direction(frame.x().vector());
450        self.entity(format!("AXIS2_PLACEMENT_3D('',#{origin},#{z},#{x})"))
451    }
452
453    /// A rigid transform as the frame it carries the world onto.
454    fn placement_frame(&mut self, at: &Transform) -> OgeomResult<u64> {
455        let origin = at.apply(Point::ORIGIN);
456        let z = at.apply_vector(Vector::new(0.0, 0.0, 1.0));
457        let x = at.apply_vector(Vector::new(1.0, 0.0, 0.0));
458        let frame = Frame::new(
459            origin,
460            ogeom_math::Direction::new(z, self.tol)?,
461            ogeom_math::Direction::new(x, self.tol)?,
462            self.tol,
463        )
464        .map_err(|_| {
465            ogeom_core::ogeom_err!(
466                Construction,
467                "an instance placement is not rigid; STEP cannot state it"
468            )
469        })?;
470        Ok(self.frame(&frame))
471    }
472
473    /// A sheet as a `SHELL_BASED_SURFACE_MODEL` of one shell: a free shell
474    /// as an `OPEN_SHELL`, or a `CLOSED_SHELL` when it closes, and a lone
475    /// face (`lone`) as an `OPEN_SHELL` of that face alone.
476    fn surface_model(&mut self, sheet: &Shape, lone: bool) -> OgeomResult<u64> {
477        let faces = if lone {
478            vec![sheet.clone()]
479        } else {
480            self.model.ordered_children_of(sheet)?
481        };
482        if faces.is_empty() {
483            ogeom_bail!(Construction, "a shell with no face cannot be written");
484        }
485        let keyword = if !lone && ogeom_algo::is_shell_closed(self.model, sheet)? {
486            "CLOSED_SHELL"
487        } else {
488            "OPEN_SHELL"
489        };
490        let mut ids = Vec::with_capacity(faces.len());
491        for face in &faces {
492            ids.push(self.face(face)?);
493        }
494        let list = reference_list(&ids);
495        let shell = self.entity(format!("{keyword}('',({list}))"));
496        let model = self.entity(format!("SHELL_BASED_SURFACE_MODEL('',(#{shell}))"));
497        if !lone {
498            self.written_nodes.push((sheet.node(), model));
499        }
500        Ok(model)
501    }
502
503    /// A solid, with its voids when it has any: the first shell is the
504    /// outer boundary, and each further shell bounds a cavity. A void is
505    /// written as the standard names it, a closed shell facing out of the
506    /// cavity used the other way round, which leaves every face of the
507    /// solid facing away from the material.
508    fn solid(&mut self, solid: &Shape) -> OgeomResult<u64> {
509        let shells = explore(self.model, solid, Filter::OfType(ShapeType::Shell))?;
510        let Some((outer, voids)) = shells.split_first() else {
511            ogeom_bail!(Construction, "a solid with no shell cannot be written");
512        };
513        let shell_id = self.closed_shell(outer, false)?;
514        let msb = if voids.is_empty() {
515            self.entity(format!("MANIFOLD_SOLID_BREP('',#{shell_id})"))
516        } else {
517            let mut uses = Vec::with_capacity(voids.len());
518            for void in voids {
519                let own = self.closed_shell(void, true)?;
520                uses.push(self.entity(format!("ORIENTED_CLOSED_SHELL('',*,#{own},.F.)")));
521            }
522            let list = uses
523                .iter()
524                .map(|i| format!("#{i}"))
525                .collect::<Vec<_>>()
526                .join(",");
527            self.entity(format!("BREP_WITH_VOIDS('',#{shell_id},({list}))"))
528        };
529        self.written_nodes.push((solid.node(), msb));
530        Ok(msb)
531    }
532
533    /// A shell's faces as a `CLOSED_SHELL`, each turned when `turned`.
534    fn closed_shell(&mut self, shell: &Shape, turned: bool) -> OgeomResult<u64> {
535        let mut faces = Vec::new();
536        for face in self.model.ordered_children_of(shell)? {
537            let face = if turned { face.reversed() } else { face };
538            faces.push(self.face(&face)?);
539        }
540        let list = faces
541            .iter()
542            .map(|i| format!("#{i}"))
543            .collect::<Vec<_>>()
544            .join(",");
545        Ok(self.entity(format!("CLOSED_SHELL('',({list}))")))
546    }
547
548    fn face(&mut self, face: &Shape) -> OgeomResult<u64> {
549        let placement = face.transform(self.model.datums())?;
550        let (surface, held) = {
551            let Some(node) = self.model.node(face) else {
552                ogeom_bail!(Dangling, "face is not in this model");
553            };
554            let NodeData::Face(data) = node.data() else {
555                ogeom_bail!(Construction, "face node holds no face data");
556            };
557            let Some(surface) = self.model.geometry().surface(data.surface) else {
558                ogeom_bail!(Dangling, "face refers to a surface not in this model");
559            };
560            (
561                surface.clone().transformed(&placement, self.tol)?,
562                data.surface,
563            )
564        };
565        let surface_id = self.surface(&surface)?;
566        // The face's pcurves mean the same points in the file only where
567        // the surface's parameters do.
568        let chart = (rigid(&placement) && surface_states_parameters(&surface))
569            .then_some((held, surface_id));
570
571        // Each loop walked under the face's sense, in the order the face
572        // stores them: the outer one first, which the walk of a reversed
573        // face lists last. A loop keeps the face's interior on its left
574        // about the side the face faces, and a mirroring placement walks it
575        // the other way round: its bound then says so.
576        let along = if placement.preserves_handedness() {
577            ".T."
578        } else {
579            ".F."
580        };
581        let wires = self.model.children_of(face)?;
582        // How many times the face walks each edge: a seam edge walked once
583        // bounds the face on one side of its surface's seam only.
584        let mut uses: FastMap<ogeom_topo::TShapeId, usize> = FastMap::default();
585        for wire in &wires {
586            for edge in self.model.ordered_children_of(wire)? {
587                *uses.entry(edge.node()).or_default() += 1;
588            }
589        }
590        let mut bounds = Vec::new();
591        for (index, wire) in wires.iter().enumerate() {
592            let keyword = if index == 0 {
593                "FACE_OUTER_BOUND"
594            } else {
595                "FACE_BOUND"
596            };
597            let loop_id = self.wire(wire, chart, &uses)?;
598            bounds.push(self.entity(format!("{keyword}('',#{loop_id},{along})")));
599        }
600        let list = bounds
601            .iter()
602            .map(|i| format!("#{i}"))
603            .collect::<Vec<_>>()
604            .join(",");
605        // The face faces its surface's normal as the file states it, unless
606        // the placement or the file turned that normal against the face's
607        // own (see `turned_in_file`).
608        let forward = face.orientation() != ogeom_topo::Orientation::Reversed;
609        let turned =
610            crate::bodies::turned_in_file(&placement, crate::bodies::restates(&surface, true));
611        let sense = if forward == turned { ".F." } else { ".T." };
612        let id = self.entity(format!("ADVANCED_FACE('',({list}),#{surface_id},{sense})"));
613        self.written_nodes.push((face.node(), id));
614        Ok(id)
615    }
616
617    /// A wire as an `EDGE_LOOP`, or a `VERTEX_LOOP` when every edge in it is
618    /// degenerate: a pole or an apex has no curve to serialize, and STEP's
619    /// own spelling for it is the loop of one vertex. Each edge's pcurve on
620    /// `chart`, the face's surface and its entity, is noted for its edge.
621    fn wire(
622        &mut self,
623        wire: &Shape,
624        chart: Option<(SurfaceId, u64)>,
625        uses: &FastMap<ogeom_topo::TShapeId, usize>,
626    ) -> OgeomResult<u64> {
627        let children = self.model.ordered_children_of(wire)?;
628        let degenerate = |edge: &Shape| {
629            self.model
630                .node(edge)
631                .and_then(|n| n.data().as_edge())
632                .is_some_and(|d| d.degenerate)
633        };
634        if !children.is_empty() && children.iter().all(degenerate) {
635            let edge = &children[0];
636            let Some(vertex) = self.model.children_of(edge)?.first().cloned() else {
637                ogeom_bail!(Construction, "a degenerate edge has no vertex");
638            };
639            let vertex_id = self.vertex(&vertex, &edge.transform(self.model.datums())?)?;
640            return Ok(self.entity(format!("VERTEX_LOOP('',#{vertex_id})")));
641        }
642        let mut oriented = Vec::new();
643        for (k, edge) in children.iter().enumerate() {
644            if degenerate(edge) {
645                continue;
646            }
647            let edge_id = self.edge(edge)?;
648            if let Some(chart) = chart {
649                let side = if uses.get(&edge.node()).copied().unwrap_or(0) == 1 {
650                    let n = children.len();
651                    let neighbours = (&children[(k + n - 1) % n], &children[(k + 1) % n]);
652                    self.seam_side_walked(edge, neighbours, chart.0)
653                } else {
654                    None
655                };
656                self.note_pcurves(edge, edge_id, chart, side);
657            }
658            let sense = if edge.orientation() == ogeom_topo::Orientation::Reversed {
659                ".F."
660            } else {
661                ".T."
662            };
663            oriented.push(self.entity(format!("ORIENTED_EDGE('',*,*,#{edge_id},{sense})")));
664        }
665        let list = oriented
666            .iter()
667            .map(|i| format!("#{i}"))
668            .collect::<Vec<_>>()
669            .join(",");
670        Ok(self.entity(format!("EDGE_LOOP('',({list}))")))
671    }
672
673    fn edge(&mut self, edge: &Shape) -> OgeomResult<u64> {
674        let placement = edge.transform(self.model.datums())?;
675        let key = (edge.node(), transform_bits(&placement));
676        if let Some(&id) = self.edges.get(&key) {
677            return Ok(id);
678        }
679        let (curve, range) = {
680            let Some(node) = self.model.node(edge) else {
681                ogeom_bail!(Dangling, "edge is not in this model");
682            };
683            let Some(data) = node.data().as_edge() else {
684                ogeom_bail!(Construction, "edge node holds no edge data");
685            };
686            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
687                ogeom_bail!(Construction, "an edge with no curve cannot be written");
688            };
689            let Some(geometry) = self.model.geometry().curve(*curve) else {
690                ogeom_bail!(Dangling, "curve is not in this model");
691            };
692            (geometry.clone().transformed(&placement, self.tol)?, *range)
693        };
694        let exact = curve_states_parameter(&curve);
695        let curve_id = self.curve(&curve, range)?;
696        let vertices = self.model.children_of(edge)?;
697        let (from, to) = match vertices.len() {
698            0 => ogeom_bail!(Construction, "an edge with no vertices cannot be written"),
699            1 => (vertices[0].clone(), vertices[0].clone()),
700            _ => (vertices[0].clone(), vertices[vertices.len() - 1].clone()),
701        };
702        // Each vertex's own composed placement, not the edge's: children_of
703        // already folds the edge's chain in, and an instanced vertex adds a
704        // hop of its own that the edge's placement alone would drop.
705        let from_placement = from.transform(self.model.datums())?;
706        let to_placement = to.transform(self.model.datums())?;
707        let from_id = self.vertex(&from, &from_placement)?;
708        let to_id = self.vertex(&to, &to_placement)?;
709        let id = self.entity(format!(
710            "EDGE_CURVE('',#{from_id},#{to_id},#{curve_id},.T.)"
711        ));
712        self.edges.insert(key, id);
713        self.written_edges.push((edge.node(), id));
714        self.surface_curves.insert(
715            id,
716            EdgeCurve {
717                from: from_id,
718                to: to_id,
719                curve: curve_id,
720                range,
721                exact,
722                pcurves: Vec::new(),
723            },
724        );
725        Ok(id)
726    }
727
728    /// Which of a seam edge's two pcurves on surface `held` a face walking
729    /// it once uses: the one whose ends, walked as the face walks the edge,
730    /// meet the ends of the edges before and after it in the ring (`true`
731    /// for its forward pcurve). `None` where `edge` holds no seam there or
732    /// neither neighbour states a single pcurve to read the joints off.
733    fn seam_side_walked(
734        &self,
735        edge: &Shape,
736        (before, after): (&Shape, &Shape),
737        held: SurfaceId,
738    ) -> Option<bool> {
739        let walked = |shape: &Shape, id: ogeom_topo::PCurveId, range: (f64, f64)| {
740            let pcurve = self.model.geometry().pcurve(id)?;
741            let (a, b) = (
742                pcurve.point_at(range.0, self.tol).ok()?,
743                pcurve.point_at(range.1, self.tol).ok()?,
744            );
745            Some(
746                if shape.orientation() == ogeom_topo::Orientation::Reversed {
747                    (b, a)
748                } else {
749                    (a, b)
750                },
751            )
752        };
753        let single = |shape: &Shape| -> Option<(Point2, Point2)> {
754            let data = self.model.node(shape)?.data().as_edge()?;
755            match data.pcurve_for(held, shape.location()) {
756                Some(EdgeRepr::PCurve { curve, range, .. }) => walked(shape, *curve, *range),
757                _ => None,
758            }
759        };
760        let data = self.model.node(edge)?.data().as_edge()?;
761        let Some(EdgeRepr::Seam {
762            forward,
763            reversed,
764            range,
765            ..
766        }) = data.pcurve_for(held, edge.location())
767        else {
768            return None;
769        };
770        let (prior, next) = (single(before), single(after));
771        if prior.is_none() && next.is_none() {
772            return None;
773        }
774        let miss = |id: ogeom_topo::PCurveId| -> Option<f64> {
775            let (start, end) = walked(edge, id, *range)?;
776            Some(
777                prior.map_or(0.0, |(_, e)| e.distance(start))
778                    + next.map_or(0.0, |(s, _)| end.distance(s)),
779            )
780        };
781        let (on_forward, on_reversed) = (miss(*forward)?, miss(*reversed)?);
782        (on_forward != on_reversed).then_some(on_forward < on_reversed)
783    }
784
785    /// Note the pcurves `edge` holds on a face's surface, the surface and
786    /// the entity written for it, where they state the edge over its own
787    /// range in a form the file carries exactly. Of a seam the face walks
788    /// once, only the side `side` names (see [`Self::seam_side_walked`]):
789    /// a `SEAM_CURVE` states an edge a face walks twice, and a reader
790    /// handed both sides for one walk may take the wrong one.
791    fn note_pcurves(
792        &mut self,
793        edge: &Shape,
794        edge_id: u64,
795        (held, surface_id): (SurfaceId, u64),
796        side: Option<bool>,
797    ) {
798        let Some(record) = self.surface_curves.get_mut(&edge_id) else {
799            return;
800        };
801        if !record.exact || record.pcurves.iter().any(|(s, ..)| *s == surface_id) {
802            return;
803        }
804        let Some(data) = self.model.node(edge).and_then(|n| n.data().as_edge()) else {
805            return;
806        };
807        let (ids, range) = match data.pcurve_for(held, edge.location()) {
808            Some(EdgeRepr::PCurve { curve, range, .. }) => (vec![*curve], *range),
809            Some(EdgeRepr::Seam {
810                forward,
811                reversed,
812                range,
813                ..
814            }) => match side {
815                Some(true) => (vec![*forward], *range),
816                Some(false) => (vec![*reversed], *range),
817                None => (vec![*forward, *reversed], *range),
818            },
819            _ => return,
820        };
821        let Some(pace) = Pace::between(record.range, range, self.tol.parametric()) else {
822            return;
823        };
824        let mut curves = Vec::with_capacity(ids.len());
825        for id in ids {
826            match self.model.geometry().pcurve(id) {
827                Some(pcurve) if planar_stated(pcurve, pace) => curves.push(pcurve.clone()),
828                _ => return,
829            }
830        }
831        record.pcurves.push((surface_id, curves, pace));
832    }
833
834    /// Every written edge that gathered pcurves, rewritten over a
835    /// `SURFACE_CURVE` carrying them, or a `SEAM_CURVE` where its two lie
836    /// on one face's surface. The `EDGE_CURVE` keeps its entity id, so
837    /// whatever already refers to it still does.
838    fn surface_curves(&mut self) -> OgeomResult<()> {
839        let records = std::mem::take(&mut self.surface_curves);
840        for (edge_id, record) in &records {
841            let count: usize = record.pcurves.iter().map(|(_, c, _)| c.len()).sum();
842            let keyword = match (record.pcurves.len(), count) {
843                (1, 2) => "SEAM_CURVE",
844                (1, 1) | (2, 2) => "SURFACE_CURVE",
845                _ => continue,
846            };
847            let mut uses = Vec::with_capacity(count);
848            for (surface_id, curves, pace) in &record.pcurves {
849                for curve in curves {
850                    let planar = self.planar_curve(curve, *pace)?;
851                    let context = self.parametric_context();
852                    let definition = self.entity(format!(
853                        "DEFINITIONAL_REPRESENTATION('',(#{planar}),#{context})"
854                    ));
855                    uses.push(self.entity(format!("PCURVE('',#{surface_id},#{definition})")));
856                }
857            }
858            let list = reference_list(&uses);
859            let curve = self.entity(format!(
860                "{keyword}('',#{},({list}),.PCURVE_S1.)",
861                record.curve
862            ));
863            let (from, to) = (record.from, record.to);
864            let Some(text) = usize::try_from(*edge_id)
865                .ok()
866                .and_then(|i| i.checked_sub(1))
867                .and_then(|i| self.entities.get_mut(i))
868            else {
869                ogeom_bail!(Construction, "an edge entity was lost before its pcurves");
870            };
871            *text = format!("EDGE_CURVE('',#{from},#{to},#{curve},.T.)");
872        }
873        self.surface_curves = records;
874        Ok(())
875    }
876
877    /// The context a pcurve's representation lives in: two dimensions of
878    /// a surface's parameters.
879    fn parametric_context(&mut self) -> u64 {
880        if let Some(id) = self.parametric_context {
881            return id;
882        }
883        let id = self.entity(
884            "(GEOMETRIC_REPRESENTATION_CONTEXT(2)PARAMETRIC_REPRESENTATION_CONTEXT()REPRESENTATION_CONTEXT('2D SPACE',''))".into(),
885        );
886        self.parametric_context = Some(id);
887        id
888    }
889
890    fn point2(&mut self, p: Point2) -> u64 {
891        self.entity(format!("CARTESIAN_POINT('',({},{}))", real(p.x), real(p.y)))
892    }
893
894    fn direction2(&mut self, v: ogeom_math::Vector2) -> u64 {
895        self.entity(format!("DIRECTION('',({},{}))", real(v.x), real(v.y)))
896    }
897
898    fn placement2(&mut self, frame: &ogeom_math::Frame2) -> u64 {
899        let origin = self.point2(frame.origin());
900        let x = self.direction2(frame.x().vector());
901        self.entity(format!("AXIS2_PLACEMENT_2D('',#{origin},#{x})"))
902    }
903
904    /// A pcurve [`planar_stated`] admits, over its edge's curve's
905    /// parameter: at the curve's `t`, the pcurve's point at `pace`'s image
906    /// of `t`.
907    fn planar_curve(&mut self, curve: &PlanarCurve, pace: Pace) -> OgeomResult<u64> {
908        match curve {
909            PlanarCurve::Line(line) => {
910                let axis = line.axis();
911                let direction = axis.direction.vector();
912                let origin = self.point2(axis.location + direction * pace.shift);
913                let d = self.direction2(direction);
914                let vector = self.entity(format!("VECTOR('',#{d},{})", real(pace.scale)));
915                Ok(self.entity(format!("LINE('',#{origin},#{vector})")))
916            }
917            PlanarCurve::Circle(c) => {
918                let circle = c.circle();
919                let frame = self.placement2(&circle.frame());
920                Ok(self.entity(format!("CIRCLE('',#{frame},{})", real(circle.radius()))))
921            }
922            PlanarCurve::Ellipse(e) => {
923                let ellipse = e.ellipse();
924                let frame = self.placement2(&ellipse.frame());
925                Ok(self.entity(format!(
926                    "ELLIPSE('',#{frame},{},{})",
927                    real(ellipse.major_radius()),
928                    real(ellipse.minor_radius())
929                )))
930            }
931            PlanarCurve::BSpline(b) => {
932                let control: Vec<String> = b
933                    .control_points()
934                    .iter()
935                    .map(|c| {
936                        let p = Point2::from_vector(c.scaled.to_vector() / c.weight);
937                        format!("#{}", self.point2(p))
938                    })
939                    .collect();
940                let (mults, knots) = compress_knots(b.knots().knots());
941                let knots: Vec<f64> = if pace == Pace::SAME {
942                    knots
943                } else {
944                    knots
945                        .iter()
946                        .map(|k| (k - pace.shift) / pace.scale)
947                        .collect()
948                };
949                let degree = b.knots().degree();
950                let control = control.join(",");
951                let mults = mults
952                    .iter()
953                    .map(ToString::to_string)
954                    .collect::<Vec<_>>()
955                    .join(",");
956                let knots = knots.iter().map(|k| real(*k)).collect::<Vec<_>>().join(",");
957                if b.is_rational() {
958                    let weights = b
959                        .control_points()
960                        .iter()
961                        .map(|c| real(c.weight))
962                        .collect::<Vec<_>>()
963                        .join(",");
964                    Ok(self.entity(format!(
965                        "(BOUNDED_CURVE()B_SPLINE_CURVE({degree},({control}),.UNSPECIFIED.,.F.,.F.)B_SPLINE_CURVE_WITH_KNOTS(({mults}),({knots}),.UNSPECIFIED.)CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE(({weights}))REPRESENTATION_ITEM(''))"
966                    )))
967                } else {
968                    Ok(self.entity(format!(
969                        "B_SPLINE_CURVE_WITH_KNOTS('',{degree},({control}),.UNSPECIFIED.,.F.,.F.,({mults}),({knots}),.UNSPECIFIED.)"
970                    )))
971                }
972            }
973            PlanarCurve::Trimmed(t) => self.planar_curve(t.basis(), pace),
974            PlanarCurve::Hyperbola(_) | PlanarCurve::Offset(_) | PlanarCurve::Trig(_) => {
975                ogeom_bail!(
976                    Construction,
977                    "a pcurve with no exact spelling in the file was admitted"
978                )
979            }
980        }
981    }
982
983    fn vertex(&mut self, vertex: &Shape, placement: &Transform) -> OgeomResult<u64> {
984        let Some(data) = self.model.node(vertex).and_then(|n| n.data().as_vertex()) else {
985            ogeom_bail!(Construction, "vertex node holds no vertex data");
986        };
987        let at = placement.apply(data.point);
988        let key = (
989            vertex.node(),
990            [at.x.to_bits(), at.y.to_bits(), at.z.to_bits()],
991        );
992        if let Some(&id) = self.vertices.get(&key) {
993            return Ok(id);
994        }
995        let point = self.point(at);
996        let id = self.entity(format!("VERTEX_POINT('',#{point})"));
997        self.vertices.insert(key, id);
998        Ok(id)
999    }
1000
1001    /// A curve for an `EDGE_CURVE`: the analytic spelling where STEP has
1002    /// one, the exact B-spline conversion where it does not.
1003    fn curve(&mut self, curve: &Curve, range: (f64, f64)) -> OgeomResult<u64> {
1004        match curve {
1005            Curve::Line(line) => {
1006                let axis = line.axis();
1007                let origin = self.point(axis.location);
1008                let d = self.direction(axis.direction.vector());
1009                let vector = self.entity(format!("VECTOR('',#{d},1.0)"));
1010                Ok(self.entity(format!("LINE('',#{origin},#{vector})")))
1011            }
1012            // A circle or ellipse running backwards goes out as its forward
1013            // spelling: STEP's runs counter-clockwise about its axis.
1014            Curve::Circle(c) => {
1015                let circle = c.forward(self.tol)?.circle();
1016                let frame = self.conic_frame(&circle.frame());
1017                Ok(self.entity(format!("CIRCLE('',#{frame},{})", real(circle.radius()))))
1018            }
1019            Curve::Ellipse(el) => {
1020                let ellipse = el.forward(self.tol)?.ellipse();
1021                let frame = self.conic_frame(&ellipse.frame());
1022                Ok(self.entity(format!(
1023                    "ELLIPSE('',#{frame},{},{})",
1024                    real(ellipse.major_radius()),
1025                    real(ellipse.minor_radius())
1026                )))
1027            }
1028            Curve::BSpline(b) => self.bspline_curve(b),
1029            // The open conics in STEP's own spelling, where their parameter
1030            // runs STEP's way; a reversed one is written as its spline.
1031            Curve::Hyperbola(h) if !h.is_reversed() => {
1032                let hyperbola = h.hyperbola();
1033                let frame = self.conic_frame(&hyperbola.frame());
1034                Ok(self.entity(format!(
1035                    "HYPERBOLA('',#{frame},{},{})",
1036                    real(hyperbola.major_radius()),
1037                    real(hyperbola.minor_radius())
1038                )))
1039            }
1040            Curve::Parabola(p) if !p.is_reversed() => {
1041                let parabola = p.parabola();
1042                let frame = self.conic_frame(&parabola.frame());
1043                Ok(self.entity(format!("PARABOLA('',#{frame},{})", real(parabola.focal()))))
1044            }
1045            Curve::Offset(o) => {
1046                let basis = self.curve(o.basis(), range)?;
1047                let reference = self.direction(o.reference().vector());
1048                Ok(self.entity(format!(
1049                    "OFFSET_CURVE_3D('',#{basis},{},.F.,#{reference})",
1050                    real(o.distance())
1051                )))
1052            }
1053            other => {
1054                // Exact for the conics and trims: the conversion is exact,
1055                // not a fit. A helix refuses inside the
1056                // conversion: it has no exact spline form, and writing a
1057                // fit without saying so is the lie this crate does not tell.
1058                let spline = other.to_bspline_over(range, self.tol)?;
1059                self.bspline_curve(&spline)
1060            }
1061        }
1062    }
1063
1064    fn bspline_curve(&mut self, spline: &ogeom_geom::BSplineCurve) -> OgeomResult<u64> {
1065        let control: Vec<String> = spline
1066            .control_points()
1067            .iter()
1068            .map(|c| {
1069                let p = Point::from_vector(c.scaled.to_vector() / c.weight);
1070                format!("#{}", self.point(p))
1071            })
1072            .collect();
1073        let (mults, knots) = compress_knots(spline.knots().knots());
1074        let degree = spline.knots().degree();
1075        let rational = spline
1076            .control_points()
1077            .iter()
1078            .any(|c| (c.weight - 1.0).abs() > 1e-12);
1079        let control = control.join(",");
1080        let mults = mults
1081            .iter()
1082            .map(ToString::to_string)
1083            .collect::<Vec<_>>()
1084            .join(",");
1085        let knots = knots.iter().map(|k| real(*k)).collect::<Vec<_>>().join(",");
1086        if rational {
1087            let weights = spline
1088                .control_points()
1089                .iter()
1090                .map(|c| real(c.weight))
1091                .collect::<Vec<_>>()
1092                .join(",");
1093            Ok(self.entity(format!(
1094                "(BOUNDED_CURVE()B_SPLINE_CURVE({degree},({control}),.UNSPECIFIED.,.F.,.F.)B_SPLINE_CURVE_WITH_KNOTS(({mults}),({knots}),.UNSPECIFIED.)CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE(({weights}))REPRESENTATION_ITEM(''))"
1095            )))
1096        } else {
1097            Ok(self.entity(format!(
1098                "B_SPLINE_CURVE_WITH_KNOTS('',{degree},({control}),.UNSPECIFIED.,.F.,.F.,({mults}),({knots}),.UNSPECIFIED.)"
1099            )))
1100        }
1101    }
1102
1103    /// A surface: analytic where STEP has the word, the exact B-spline patch
1104    /// where it does not. Trimmed surfaces write their basis; the trim is
1105    /// the face's own topology.
1106    fn surface(&mut self, surface: &SurfaceGeometry) -> OgeomResult<u64> {
1107        match surface {
1108            SurfaceGeometry::Plane(s) => {
1109                let frame = self.frame(&s.plane().frame());
1110                Ok(self.entity(format!("PLANE('',#{frame})")))
1111            }
1112            SurfaceGeometry::Cylinder(s) => {
1113                let cylinder = s.cylinder();
1114                let frame = self.frame(&cylinder.frame());
1115                Ok(self.entity(format!(
1116                    "CYLINDRICAL_SURFACE('',#{frame},{})",
1117                    real(cylinder.radius())
1118                )))
1119            }
1120            SurfaceGeometry::Cone(s) => {
1121                let cone = s.cone();
1122                let frame = self.frame(&cone.frame());
1123                Ok(self.entity(format!(
1124                    "CONICAL_SURFACE('',#{frame},{},{})",
1125                    real(cone.radius_at(0.0)),
1126                    real(cone.half_angle())
1127                )))
1128            }
1129            SurfaceGeometry::Sphere(s) => {
1130                let sphere = s.sphere();
1131                let frame = self.frame(&sphere.frame());
1132                Ok(self.entity(format!(
1133                    "SPHERICAL_SURFACE('',#{frame},{})",
1134                    real(sphere.radius())
1135                )))
1136            }
1137            SurfaceGeometry::Torus(s) => {
1138                let torus = s.torus();
1139                let frame = self.frame(&torus.frame());
1140                Ok(self.entity(format!(
1141                    "TOROIDAL_SURFACE('',#{frame},{},{})",
1142                    real(torus.major_radius()),
1143                    real(torus.minor_radius())
1144                )))
1145            }
1146            SurfaceGeometry::BSpline(b) => self.bspline_surface(b),
1147            SurfaceGeometry::Trimmed(t) => self.surface(t.basis()),
1148            // Swept and offset surfaces in STEP's own spelling: exact, and
1149            // read back as themselves.
1150            SurfaceGeometry::Revolution(r) => {
1151                let curve = r.curve();
1152                let range = ogeom_geom::Curve3d::domain(curve);
1153                let swept = self.curve(curve, range)?;
1154                let axis = r.axis();
1155                let location = self.point(axis.location);
1156                let direction = self.direction(axis.direction.vector());
1157                let placement =
1158                    self.entity(format!("AXIS1_PLACEMENT('',#{location},#{direction})"));
1159                Ok(self.entity(format!("SURFACE_OF_REVOLUTION('',#{swept},#{placement})")))
1160            }
1161            SurfaceGeometry::Extrusion(e) => {
1162                let curve = e.curve();
1163                let range = ogeom_geom::Curve3d::domain(curve);
1164                let swept = self.curve(curve, range)?;
1165                let direction = self.direction(e.direction().vector());
1166                let vector = self.entity(format!("VECTOR('',#{direction},1.0)"));
1167                Ok(self.entity(format!(
1168                    "SURFACE_OF_LINEAR_EXTRUSION('',#{swept},#{vector})"
1169                )))
1170            }
1171            // The offset runs along the basis's normal, which the file
1172            // states turned back where it restates the basis.
1173            SurfaceGeometry::Offset(o) => {
1174                let basis = self.surface(o.basis())?;
1175                let distance = if crate::bodies::restates(o.basis(), true) {
1176                    -o.distance()
1177                } else {
1178                    o.distance()
1179                };
1180                Ok(self.entity(format!(
1181                    "OFFSET_SURFACE('',#{basis},{},.F.)",
1182                    real(distance)
1183                )))
1184            }
1185        }
1186    }
1187
1188    fn bspline_surface(&mut self, patch: &ogeom_geom::BSplineSurface) -> OgeomResult<u64> {
1189        let grid = patch.grid();
1190        let (nu, nv) = (grid.u_count(), grid.v_count());
1191        let mut rows = Vec::with_capacity(nu);
1192        let mut weights_rows = Vec::with_capacity(nu);
1193        let mut rational = false;
1194        for u in 0..nu {
1195            let mut row = Vec::with_capacity(nv);
1196            let mut wrow = Vec::with_capacity(nv);
1197            for v in 0..nv {
1198                let Some(c) = grid.get(u, v) else {
1199                    ogeom_bail!(Construction, "a control grid cell is missing");
1200                };
1201                let p = Point::from_vector(c.scaled.to_vector() / c.weight);
1202                row.push(format!("#{}", self.point(p)));
1203                wrow.push(real(c.weight));
1204                rational |= (c.weight - 1.0).abs() > 1e-12;
1205            }
1206            rows.push(format!("({})", row.join(",")));
1207            weights_rows.push(format!("({})", wrow.join(",")));
1208        }
1209        let grid_text = rows.join(",");
1210        let (u_deg, v_deg) = (patch.u_knots().degree(), patch.v_knots().degree());
1211        let (um, uk) = compress_knots(patch.u_knots().knots());
1212        let (vm, vk) = compress_knots(patch.v_knots().knots());
1213        let fmt_m = |m: &[usize]| {
1214            m.iter()
1215                .map(ToString::to_string)
1216                .collect::<Vec<_>>()
1217                .join(",")
1218        };
1219        let fmt_k = |k: &[f64]| k.iter().map(|v| real(*v)).collect::<Vec<_>>().join(",");
1220        let (um, uk, vm, vk) = (fmt_m(&um), fmt_k(&uk), fmt_m(&vm), fmt_k(&vk));
1221        if rational {
1222            let weights = weights_rows.join(",");
1223            Ok(self.entity(format!(
1224                "(BOUNDED_SURFACE()B_SPLINE_SURFACE({u_deg},{v_deg},({grid_text}),.UNSPECIFIED.,.F.,.F.,.F.)B_SPLINE_SURFACE_WITH_KNOTS(({um}),({vm}),({uk}),({vk}),.UNSPECIFIED.)GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE(({weights}))REPRESENTATION_ITEM('')SURFACE())"
1225            )))
1226        } else {
1227            Ok(self.entity(format!(
1228                "B_SPLINE_SURFACE_WITH_KNOTS('',{u_deg},{v_deg},({grid_text}),.UNSPECIFIED.,.F.,.F.,.F.,({um}),({vm}),({uk}),({vk}),.UNSPECIFIED.)"
1229            )))
1230        }
1231    }
1232
1233    /// A product's construction geometry as one representation, `None` when
1234    /// it has none: its points and curves in a `GEOMETRIC_CURVE_SET` of a
1235    /// `GEOMETRICALLY_BOUNDED_WIREFRAME_SHAPE_REPRESENTATION`, or, where it
1236    /// carries a surface, everything in a `GEOMETRIC_SET` of a
1237    /// `GEOMETRICALLY_BOUNDED_SURFACE_SHAPE_REPRESENTATION`; its axes as
1238    /// items of the representation itself. Every item carries its name.
1239    fn construction(
1240        &mut self,
1241        items: &[&ogeom_doc::Construction],
1242        name: &str,
1243        gctx: u64,
1244    ) -> OgeomResult<Option<u64>> {
1245        use ogeom_doc::ConstructionGeometry as G;
1246        if items.is_empty() {
1247            return Ok(None);
1248        }
1249        let mut elements = Vec::new();
1250        let mut axes = Vec::new();
1251        let mut surfaces = false;
1252        for item in items {
1253            let label = escape(&item.name);
1254            match &item.geometry {
1255                G::Point(p) => elements.push(self.entity(format!(
1256                    "CARTESIAN_POINT('{label}',({},{},{}))",
1257                    real(p.x),
1258                    real(p.y),
1259                    real(p.z)
1260                ))),
1261                G::Axis(frame) => {
1262                    if frame.handedness() == Handedness::Left {
1263                        ogeom_bail!(
1264                            Construction,
1265                            "construction axis '{}' is left-handed; an axis placement \
1266                             states a right-handed frame",
1267                            item.name
1268                        );
1269                    }
1270                    let origin = self.point(frame.origin());
1271                    let z = self.direction(frame.z().vector());
1272                    let x = self.direction(frame.x().vector());
1273                    axes.push(
1274                        self.entity(format!("AXIS2_PLACEMENT_3D('{label}',#{origin},#{z},#{x})")),
1275                    );
1276                }
1277                G::Curve { curve, range } => {
1278                    elements.push(self.construction_curve(curve, *range, &label)?);
1279                }
1280                G::Surface { surface, u, v } => {
1281                    surfaces = true;
1282                    elements.push(self.construction_surface(surface, *u, *v, &label)?);
1283                }
1284            }
1285        }
1286        let (set, representation) = if surfaces {
1287            (
1288                "GEOMETRIC_SET",
1289                "GEOMETRICALLY_BOUNDED_SURFACE_SHAPE_REPRESENTATION",
1290            )
1291        } else {
1292            (
1293                "GEOMETRIC_CURVE_SET",
1294                "GEOMETRICALLY_BOUNDED_WIREFRAME_SHAPE_REPRESENTATION",
1295            )
1296        };
1297        let mut members = Vec::with_capacity(axes.len() + 1);
1298        if !elements.is_empty() {
1299            let list = reference_list(&elements);
1300            members.push(self.entity(format!("{set}('',({list}))")));
1301        }
1302        members.extend(axes);
1303        let list = reference_list(&members);
1304        Ok(Some(self.entity(format!(
1305            "{representation}('{name}',({list}),#{gctx})"
1306        ))))
1307    }
1308
1309    /// A construction curve as a `TRIMMED_CURVE` over its range of the
1310    /// curve written as an edge's would be, trimmed at its end points, and
1311    /// at its parameters too where the file states the curve's own.
1312    fn construction_curve(
1313        &mut self,
1314        curve: &Curve,
1315        (t0, t1): (f64, f64),
1316        label: &str,
1317    ) -> OgeomResult<u64> {
1318        let basis = self.curve(curve, (t0, t1))?;
1319        let (p0, p1) = (curve.point_at(t0, self.tol)?, curve.point_at(t1, self.tol)?);
1320        let (p0, p1) = (self.point(p0), self.point(p1));
1321        let text = if curve_states_parameter(curve) {
1322            format!(
1323                "TRIMMED_CURVE('{label}',#{basis},(#{p0},PARAMETER_VALUE({})),(#{p1},PARAMETER_VALUE({})),.T.,.PARAMETER.)",
1324                real(t0),
1325                real(t1)
1326            )
1327        } else {
1328            format!("TRIMMED_CURVE('{label}',#{basis},(#{p0}),(#{p1}),.T.,.CARTESIAN.)")
1329        };
1330        Ok(self.entity(text))
1331    }
1332
1333    /// A construction surface as a `RECTANGULAR_TRIMMED_SURFACE`: over its
1334    /// window of the surface as a face's would be written, where the file
1335    /// states the surface's own parameters; else over the whole of the
1336    /// exact spline patch of that window.
1337    fn construction_surface(
1338        &mut self,
1339        surface: &SurfaceGeometry,
1340        u: (f64, f64),
1341        v: (f64, f64),
1342        label: &str,
1343    ) -> OgeomResult<u64> {
1344        let (basis, u, v) = if surface_states_parameters(surface) {
1345            (self.surface(surface)?, u, v)
1346        } else {
1347            let window = ogeom_geom::TrimmedSurface::new(surface.clone(), u, v, self.tol)?;
1348            let patch = SurfaceGeometry::Trimmed(Box::new(window)).to_bspline(self.tol)?;
1349            let (u, v) = patch.domain();
1350            (self.bspline_surface(&patch)?, u, v)
1351        };
1352        Ok(self.entity(format!(
1353            "RECTANGULAR_TRIMMED_SURFACE('{label}',#{basis},{},{},{},{},.T.,.T.)",
1354            real(u.0),
1355            real(u.1),
1356            real(v.0),
1357            real(v.1)
1358        )))
1359    }
1360
1361    /// The document's PMI, written over the aspects of the anchor part.
1362    #[allow(clippy::many_single_char_names)]
1363    #[allow(
1364        clippy::too_many_arguments,
1365        reason = "five STEP context ids that travel together"
1366    )]
1367    fn pmi(
1368        &mut self,
1369        pmi: &ogeom_doc::Pmi,
1370        views: &[ogeom_doc::View],
1371        anchor: (u64, u64),
1372        owner: &FastMap<ogeom_topo::TShapeId, (u64, u64)>,
1373        lu: u64,
1374        au: u64,
1375        gctx: u64,
1376    ) -> OgeomResult<()> {
1377        let by_node: FastMap<ogeom_topo::TShapeId, u64> = self
1378            .written_nodes
1379            .iter()
1380            .chain(&self.written_edges)
1381            .copied()
1382            .collect();
1383        // The part whose topology the items are: the product definition an
1384        // aspect of them hangs from, and the representation holding them.
1385        let part_of = |items: &[ogeom_topo::TShapeId]| -> (u64, u64) {
1386            items
1387                .iter()
1388                .find_map(|item| owner.get(item))
1389                .copied()
1390                .unwrap_or(anchor)
1391        };
1392        let aspect_for = |w: &mut Self, items: &[ogeom_topo::TShapeId]| -> u64 {
1393            let (pds, absr) = part_of(items);
1394            let aspect = w.entity(format!("SHAPE_ASPECT('','',#{pds},.T.)"));
1395            for item in items {
1396                if let Some(&step_id) = by_node.get(item) {
1397                    w.entity(format!(
1398                        "GEOMETRIC_ITEM_SPECIFIC_USAGE('','',#{aspect},#{absr},#{step_id})"
1399                    ));
1400                }
1401            }
1402            aspect
1403        };
1404
1405        // Which STEP id each annotation was written as, so the presentation
1406        // below can point a callout at the annotation it draws.
1407        let mut annotation_ids: FastMap<ogeom_doc::Annotated, u64> = FastMap::default();
1408        let mut datum_ids: FastMap<&str, u64> = FastMap::default();
1409        for datum in &pmi.datums {
1410            let label = escape(&datum.label);
1411            let (pds, absr) = part_of(&datum.items);
1412            let id = self.entity(format!("DATUM('','',#{pds},.F.,'{label}')"));
1413            for item in &datum.items {
1414                if let Some(&step_id) = by_node.get(item) {
1415                    self.entity(format!(
1416                        "GEOMETRIC_ITEM_SPECIFIC_USAGE('','',#{id},#{absr},#{step_id})"
1417                    ));
1418                }
1419            }
1420            datum_ids.insert(datum.label.as_str(), id);
1421            annotation_ids.insert(ogeom_doc::Annotated::Datum(datum_ids.len() - 1), id);
1422        }
1423
1424        for (at, dimension) in pmi.dimensions.iter().enumerate() {
1425            let name = escape(&dimension.name);
1426            let angular = dimension.kind == ogeom_doc::MeasureKind::Angle;
1427            let dim = if dimension.location {
1428                // A location runs between two features; each keeps its own
1429                // aspect, so what was read as two ends writes as two ends.
1430                let empty = Vec::new();
1431                let first = dimension.features.first().unwrap_or(&empty);
1432                let second = dimension.features.get(1).unwrap_or(first);
1433                let a = aspect_for(self, first);
1434                let b = aspect_for(self, second);
1435                if angular {
1436                    self.entity(format!("ANGULAR_LOCATION('{name}','',#{a},#{b},.EQUAL.)"))
1437                } else {
1438                    self.entity(format!("DIMENSIONAL_LOCATION('{name}','',#{a},#{b})"))
1439                }
1440            } else {
1441                let empty = Vec::new();
1442                let items = dimension.features.first().unwrap_or(&empty);
1443                let aspect = aspect_for(self, items);
1444                if angular {
1445                    self.entity(format!("ANGULAR_SIZE(#{aspect},'{name}',.EQUAL.)"))
1446                } else {
1447                    self.entity(format!("DIMENSIONAL_SIZE(#{aspect},'{name}')"))
1448                }
1449            };
1450            let measures: Vec<String> = dimension
1451                .values
1452                .iter()
1453                .map(|&v| format!("#{}", self.measure(v, dimension.kind, lu, au)))
1454                .collect();
1455            let list = measures.join(",");
1456            annotation_ids.insert(ogeom_doc::Annotated::Dimension(at), dim);
1457            let sdr = self.entity(format!(
1458                "SHAPE_DIMENSION_REPRESENTATION('',({list}),#{gctx})"
1459            ));
1460            self.entity(format!(
1461                "DIMENSIONAL_CHARACTERISTIC_REPRESENTATION(#{dim},#{sdr})"
1462            ));
1463            if dimension.plus.is_some() || dimension.minus.is_some() {
1464                let lower = self.measure(dimension.minus.unwrap_or(0.0), dimension.kind, lu, au);
1465                let upper = self.measure(dimension.plus.unwrap_or(0.0), dimension.kind, lu, au);
1466                let tv = self.entity(format!("TOLERANCE_VALUE(#{lower},#{upper})"));
1467                self.entity(format!("PLUS_MINUS_TOLERANCE(#{tv},#{dim})"));
1468            }
1469        }
1470
1471        for (at, tolerance) in pmi.tolerances.iter().enumerate() {
1472            let aspect = aspect_for(self, &tolerance.items);
1473            let name = escape(&tolerance.name);
1474            let magnitude =
1475                self.measure(tolerance.magnitude, ogeom_doc::MeasureKind::Length, lu, au);
1476            let keyword = format!("{}_TOLERANCE", tolerance.kind.to_uppercase());
1477            let system = !tolerance.datums.is_empty()
1478                && (tolerance.datum_system
1479                    || tolerance.datums.iter().any(|d| !d.modifiers.is_empty()));
1480            let refs: Vec<String> = if system {
1481                let (pds, _) = part_of(&tolerance.items);
1482                let id = self.datum_system(&tolerance.datums, &datum_ids, pds, lu, au)?;
1483                vec![format!("#{id}")]
1484            } else {
1485                // A hyphen-joined label is a composite reference: its
1486                // constituent datums go through a compartment that binds
1487                // them into one.
1488                tolerance
1489                    .datums
1490                    .iter()
1491                    .filter_map(|d| {
1492                        if let Some(id) = datum_ids.get(d.label.as_str()) {
1493                            return Some(format!("#{id}"));
1494                        }
1495                        let parts: Vec<String> = d
1496                            .label
1497                            .split('-')
1498                            .filter_map(|label| datum_ids.get(label))
1499                            .map(|i| format!("#{i}"))
1500                            .collect();
1501                        if parts.len() < 2 {
1502                            return None;
1503                        }
1504                        let list = parts.join(",");
1505                        let (pds, _) = part_of(&tolerance.items);
1506                        let compartment = self.entity(format!(
1507                            "DATUM_REFERENCE_COMPARTMENT('','',#{pds},.F.,({list}),())"
1508                        ));
1509                        Some(format!("#{compartment}"))
1510                    })
1511                    .collect()
1512            };
1513            let refs = refs.join(",");
1514            // A datum system on a kind the schema does not derive from the
1515            // datum-referencing tolerance (position, the profiles) needs
1516            // the complex form, which states the datum list on its own
1517            // part.
1518            let simple = tolerance.modifiers.is_empty()
1519                && (!system || REFERENCES_DATUMS.contains(&keyword.as_str()));
1520            let written = if simple {
1521                if refs.is_empty() {
1522                    self.entity(format!("{keyword}('{name}','',#{magnitude},#{aspect})"))
1523                } else {
1524                    self.entity(format!(
1525                        "{keyword}('{name}','',#{magnitude},#{aspect},({refs}))"
1526                    ))
1527                }
1528            } else {
1529                // The complex form: the shared attributes sit on the
1530                // GEOMETRIC_TOLERANCE part, the datum list and the modifier
1531                // words on their own parts, the subtype empty.
1532                let mut parts = vec![
1533                    format!("GEOMETRIC_TOLERANCE('{name}','',#{magnitude},#{aspect})"),
1534                    format!("{keyword}()"),
1535                ];
1536                if !tolerance.modifiers.is_empty() {
1537                    let words: Vec<String> = tolerance
1538                        .modifiers
1539                        .iter()
1540                        .map(|m| format!(".{}.", m.to_uppercase()))
1541                        .collect();
1542                    let words = words.join(",");
1543                    parts.push(format!("GEOMETRIC_TOLERANCE_WITH_MODIFIERS(({words}))"));
1544                }
1545                if !refs.is_empty() {
1546                    parts.push(format!(
1547                        "GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE(({refs}))"
1548                    ));
1549                }
1550                parts.sort();
1551                self.entity(format!("({})", parts.concat()))
1552            };
1553            annotation_ids.insert(ogeom_doc::Annotated::Tolerance(at), written);
1554        }
1555
1556        // Datum targets: the pads a datum is established at. The identifier
1557        // is the letter and the number the drawing shows (`A1`), and the
1558        // placement and sizes go in a shape representation the target's own
1559        // property definition names, which is where the reader looks for them.
1560        for target in &pmi.targets {
1561            let identifier = escape(&target.identifier());
1562            let description = match target.kind {
1563                ogeom_doc::DatumTargetKind::Point => "point",
1564                ogeom_doc::DatumTargetKind::Line { .. } => "line",
1565                ogeom_doc::DatumTargetKind::Rectangle { .. } => "rectangle",
1566                ogeom_doc::DatumTargetKind::Circle { .. } => "circle",
1567            };
1568            let (pds, absr) = part_of(&target.items);
1569            let id = self.entity(format!(
1570                "PLACED_DATUM_TARGET_FEATURE('','{description}',#{pds},.F.,'{identifier}')"
1571            ));
1572            for item in &target.items {
1573                if let Some(&step_id) = by_node.get(item) {
1574                    self.entity(format!(
1575                        "GEOMETRIC_ITEM_SPECIFIC_USAGE('','',#{id},#{absr},#{step_id})"
1576                    ));
1577                }
1578            }
1579            // Tie the target to its own datum, so a reader that meets the
1580            // target first can still say which datum it establishes.
1581            if let Some(&datum_id) = datum_ids.get(target.datum.as_str()) {
1582                self.entity(format!(
1583                    "SHAPE_ASPECT_RELATIONSHIP('','',#{datum_id},#{id})"
1584                ));
1585            }
1586            let frame = target
1587                .frame
1588                .unwrap_or_else(|| Frame::about(target.at, ogeom_math::Direction::Z));
1589            let placement = self.frame(&frame);
1590            let sizes: Vec<f64> = match target.kind {
1591                ogeom_doc::DatumTargetKind::Point => Vec::new(),
1592                ogeom_doc::DatumTargetKind::Line { length } => vec![length],
1593                ogeom_doc::DatumTargetKind::Rectangle { length, width } => vec![length, width],
1594                ogeom_doc::DatumTargetKind::Circle { diameter } => vec![diameter],
1595            };
1596            let mut items = vec![format!("#{placement}")];
1597            for size in sizes {
1598                let measure = self.measure(size, ogeom_doc::MeasureKind::Length, lu, au);
1599                items.push(format!("#{measure}"));
1600            }
1601            let list = items.join(",");
1602            let rep = self.entity(format!(
1603                "SHAPE_REPRESENTATION('{identifier}',({list}),#{gctx})"
1604            ));
1605            let property = self.entity(format!("PROPERTY_DEFINITION('','',#{id})"));
1606            self.entity(format!(
1607                "SHAPE_DEFINITION_REPRESENTATION(#{property},#{rep})"
1608            ));
1609        }
1610
1611        // Presentation: the drawn annotations. One curve set per callout over
1612        // one coordinates list, held by an occurrence, held by the callout;
1613        // the plane it is drawn in; and the association that says which
1614        // semantic annotation it is a picture of.
1615        let mut callout_ids: Vec<u64> = Vec::new();
1616        if !pmi.callouts.is_empty() {
1617            let mut drawn: Vec<(u64, ogeom_doc::Annotated)> = Vec::new();
1618            let mut noted: Vec<(u64, u64)> = Vec::new();
1619            let mut planes: Vec<String> = Vec::new();
1620            for callout in &pmi.callouts {
1621                let name = escape(&callout.name);
1622                let mut coordinates: Vec<String> = Vec::new();
1623                let mut lines: Vec<String> = Vec::new();
1624                let mut next = 1_usize;
1625                for polyline in &callout.polylines {
1626                    let mut indices: Vec<String> = Vec::with_capacity(polyline.len());
1627                    for p in polyline {
1628                        coordinates.push(format!("({},{},{})", real(p.x), real(p.y), real(p.z)));
1629                        indices.push(next.to_string());
1630                        next += 1;
1631                    }
1632                    lines.push(format!("({})", indices.join(",")));
1633                }
1634                let count = coordinates.len();
1635                let coordinates = coordinates.join(",");
1636                let list = self.entity(format!(
1637                    "COORDINATES_LIST('{name}',{count},({coordinates}))"
1638                ));
1639                let lines = lines.join(",");
1640                let set = self.entity(format!("TESSELLATED_CURVE_SET('{name}',#{list},({lines}))"));
1641                // No style is written, and that is a statement rather than an
1642                // omission: a style is about rendering, and this kernel keeps
1643                // no draughting style model to have one from.
1644                let occurrence = self.entity(format!(
1645                    "TESSELLATED_ANNOTATION_OCCURRENCE('{name}',(),#{set})"
1646                ));
1647                let mut held = vec![format!("#{occurrence}")];
1648                // The words as text: a literal on the callout's own plane,
1649                // or the world's, in a text occurrence beside the strokes.
1650                if let Some(words) = &callout.text {
1651                    let words = escape(words);
1652                    let placement = self.frame(&callout.plane.unwrap_or(Frame::WORLD));
1653                    let font =
1654                        self.entity("DRAUGHTING_PRE_DEFINED_TEXT_FONT('ISO 3098')".to_string());
1655                    let literal = self.entity(format!(
1656                        "TEXT_LITERAL('{name}','{words}',#{placement},'baseline left',.RIGHT.,#{font})"
1657                    ));
1658                    let text = self.entity(format!(
1659                        "ANNOTATION_TEXT_OCCURRENCE('{name}',(),#{literal})"
1660                    ));
1661                    held.push(format!("#{text}"));
1662                }
1663                let held = held.join(",");
1664                let id = self.entity(format!("DRAUGHTING_CALLOUT('{name}',({held}))"));
1665                callout_ids.push(id);
1666                if let Some(frame) = callout.plane {
1667                    let placement = self.frame(&frame);
1668                    let plane = self.entity(format!("PLANE('{name}',#{placement})"));
1669                    planes.push(
1670                        self.entity(format!("ANNOTATION_PLANE('{name}',(),#{plane},(#{id}))"))
1671                            .to_string(),
1672                    );
1673                }
1674                if let Some(annotates) = callout.annotates {
1675                    drawn.push((id, annotates));
1676                } else if !callout.items.is_empty() {
1677                    // A note about topology: an aspect of it, on its own part,
1678                    // that the callout is the picture of.
1679                    noted.push((id, aspect_for(self, &callout.items)));
1680                }
1681            }
1682            let list: Vec<String> = planes.iter().map(|p| format!("#{p}")).collect();
1683            let list = list.join(",");
1684            let model = self.entity(format!("DRAUGHTING_MODEL('',({list}),#{gctx})"));
1685            for (callout, aspect) in noted {
1686                self.entity(format!(
1687                    "DRAUGHTING_MODEL_ITEM_ASSOCIATION('PMI representation to presentation \
1688                     link','',#{aspect},#{model},#{callout})"
1689                ));
1690            }
1691            for (callout, annotates) in drawn {
1692                let Some(&annotation) = annotation_ids.get(&annotates) else {
1693                    continue;
1694                };
1695                self.entity(format!(
1696                    "DRAUGHTING_MODEL_ITEM_ASSOCIATION('PMI representation to presentation \
1697                     link','',#{annotation},#{model},#{callout})"
1698                ));
1699            }
1700        }
1701
1702        // Saved views: a named draughting model per view, holding a camera
1703        // and the callouts the view presents. The camera's view volume is
1704        // written `$`: this writer keeps no viewing frustum to state, and
1705        // the readers that matter (this one included) take the name and
1706        // the placement and leave the rest.
1707        for view in views {
1708            let name = escape(&view.name);
1709            let placement = self.frame(&view.frame);
1710            let camera = self.entity(format!("CAMERA_MODEL_D3('{name}',#{placement},$)"));
1711            let mut items = vec![format!("#{camera}")];
1712            for &index in &view.callouts {
1713                if let Some(id) = callout_ids.get(index) {
1714                    items.push(format!("#{id}"));
1715                }
1716            }
1717            let items = items.join(",");
1718            self.entity(format!("DRAUGHTING_MODEL('{name}',({items}),#{gctx})"));
1719        }
1720        Ok(())
1721    }
1722
1723    /// An AP242 datum system: one compartment per reference, in precedence
1724    /// order, each naming its datum (a common datum through one element
1725    /// per constituent) and carrying its modifiers.
1726    fn datum_system(
1727        &mut self,
1728        references: &[ogeom_doc::DatumReference],
1729        datum_ids: &FastMap<&str, u64>,
1730        pds: u64,
1731        lu: u64,
1732        au: u64,
1733    ) -> OgeomResult<u64> {
1734        if references.len() > 3 {
1735            ogeom_bail!(
1736                Construction,
1737                "a datum system holds at most three references, this one {}",
1738                references.len()
1739            );
1740        }
1741        let mut compartments: Vec<String> = Vec::with_capacity(references.len());
1742        for reference in references {
1743            let base = if let Some(&id) = datum_ids.get(reference.label.as_str()) {
1744                format!("#{id}")
1745            } else {
1746                let mut elements: Vec<String> = Vec::new();
1747                for label in reference.label.split('-') {
1748                    let Some(&id) = datum_ids.get(label) else {
1749                        ogeom_bail!(
1750                            Construction,
1751                            "the datum system references `{}`, which no datum is",
1752                            reference.label
1753                        );
1754                    };
1755                    let element =
1756                        self.entity(format!("DATUM_REFERENCE_ELEMENT('','',#{pds},.F.,#{id},$)"));
1757                    elements.push(format!("#{element}"));
1758                }
1759                if elements.len() < 2 {
1760                    ogeom_bail!(
1761                        Construction,
1762                        "the datum system references `{}`, which no datum is",
1763                        reference.label
1764                    );
1765                }
1766                format!("({})", elements.join(","))
1767            };
1768            let mut modifiers: Vec<String> = Vec::with_capacity(reference.modifiers.len());
1769            for modifier in &reference.modifiers {
1770                modifiers.push(match modifier {
1771                    ogeom_doc::DatumModifier::Simple(word) => {
1772                        format!("SIMPLE_DATUM_REFERENCE_MODIFIER(.{}.)", word.to_uppercase())
1773                    }
1774                    ogeom_doc::DatumModifier::WithValue { kind, value } => {
1775                        let measure = self.measure(*value, ogeom_doc::MeasureKind::Length, lu, au);
1776                        let id = self.entity(format!(
1777                            "DATUM_REFERENCE_MODIFIER_WITH_VALUE(.{}.,#{measure})",
1778                            kind.word().to_uppercase()
1779                        ));
1780                        format!("#{id}")
1781                    }
1782                });
1783            }
1784            let modifiers = if modifiers.is_empty() {
1785                "$".to_owned()
1786            } else {
1787                format!("({})", modifiers.join(","))
1788            };
1789            let compartment = self.entity(format!(
1790                "DATUM_REFERENCE_COMPARTMENT('','',#{pds},.F.,{base},{modifiers})"
1791            ));
1792            compartments.push(format!("#{compartment}"));
1793        }
1794        let list = compartments.join(",");
1795        Ok(self.entity(format!("DATUM_SYSTEM('','',#{pds},.F.,({list}))")))
1796    }
1797
1798    /// A measure representation item, in the document's own units.
1799    fn measure(&mut self, value: f64, kind: ogeom_doc::MeasureKind, lu: u64, au: u64) -> u64 {
1800        let v = real(value);
1801        match kind {
1802            ogeom_doc::MeasureKind::Length => self.entity(format!(
1803                "(LENGTH_MEASURE_WITH_UNIT()MEASURE_REPRESENTATION_ITEM()MEASURE_WITH_UNIT(LENGTH_MEASURE({v}),#{lu})REPRESENTATION_ITEM(''))"
1804            )),
1805            ogeom_doc::MeasureKind::Angle => self.entity(format!(
1806                "(MEASURE_REPRESENTATION_ITEM()MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE({v}),#{au})PLANE_ANGLE_MEASURE_WITH_UNIT()REPRESENTATION_ITEM(''))"
1807            )),
1808        }
1809    }
1810
1811    /// A styled item colouring one written entity.
1812    fn styled_item(&mut self, item: u64, colour: ogeom_doc::Colour) -> u64 {
1813        let rgb = self.entity(format!(
1814            "COLOUR_RGB('',{},{},{})",
1815            real(colour.r),
1816            real(colour.g),
1817            real(colour.b)
1818        ));
1819        let fasc = self.entity(format!("FILL_AREA_STYLE_COLOUR('',#{rgb})"));
1820        let fas = self.entity(format!("FILL_AREA_STYLE('',(#{fasc}))"));
1821        let ssfa = self.entity(format!("SURFACE_STYLE_FILL_AREA(#{fas})"));
1822        let sss = self.entity(format!("SURFACE_SIDE_STYLE('',(#{ssfa}))"));
1823        let ssu = self.entity(format!("SURFACE_STYLE_USAGE(.BOTH.,#{sss})"));
1824        let psa = self.entity(format!("PRESENTATION_STYLE_ASSIGNMENT((#{ssu}))"));
1825        self.entity(format!("STYLED_ITEM('',(#{psa}),#{item})"))
1826    }
1827
1828    /// A styled item colouring one written edge: a continuous curve style
1829    /// of the conventional display width.
1830    fn curve_styled_item(&mut self, item: u64, colour: ogeom_doc::Colour) -> u64 {
1831        let font = match self.curve_font {
1832            Some(font) => font,
1833            None => {
1834                let font = self.entity("DRAUGHTING_PRE_DEFINED_CURVE_FONT('continuous')".into());
1835                self.curve_font = Some(font);
1836                font
1837            }
1838        };
1839        let rgb = self.entity(format!(
1840            "COLOUR_RGB('',{},{},{})",
1841            real(colour.r),
1842            real(colour.g),
1843            real(colour.b)
1844        ));
1845        let style = self.entity(format!(
1846            "CURVE_STYLE('',#{font},POSITIVE_LENGTH_MEASURE(0.1),#{rgb})"
1847        ));
1848        let psa = self.entity(format!("PRESENTATION_STYLE_ASSIGNMENT((#{style}))"));
1849        self.entity(format!("STYLED_ITEM('',(#{psa}),#{item})"))
1850    }
1851}
1852
1853/// The tolerance kinds AP242 derives from
1854/// `geometric_tolerance_with_datum_reference`: their simple form states the
1855/// datum list itself. The others take one only in the complex form.
1856const REFERENCES_DATUMS: [&str; 8] = [
1857    "ANGULARITY_TOLERANCE",
1858    "CIRCULAR_RUNOUT_TOLERANCE",
1859    "COAXIALITY_TOLERANCE",
1860    "CONCENTRICITY_TOLERANCE",
1861    "PARALLELISM_TOLERANCE",
1862    "PERPENDICULARITY_TOLERANCE",
1863    "SYMMETRY_TOLERANCE",
1864    "TOTAL_RUNOUT_TOLERANCE",
1865];
1866
1867/// Whether a placement keeps every surface's parameters: no scale, no
1868/// mirror.
1869fn rigid(placement: &Transform) -> bool {
1870    (placement.scale_factor() - 1.0).abs() <= 1e-12 && placement.preserves_handedness()
1871}
1872
1873/// Whether a curve goes out in this kernel's own parameterization: the
1874/// analytic spellings STEP parameterizes the same way, in right-handed
1875/// frames and running forward, a spline as itself, an offset of one of
1876/// these. Everything else goes out as a conversion with a parameter of its
1877/// own.
1878fn curve_states_parameter(curve: &Curve) -> bool {
1879    match curve {
1880        Curve::Line(_) | Curve::BSpline(_) => true,
1881        Curve::Circle(c) => {
1882            !c.is_reversed() && c.circle().frame().handedness() == Handedness::Right
1883        }
1884        Curve::Ellipse(e) => {
1885            !e.is_reversed() && e.ellipse().frame().handedness() == Handedness::Right
1886        }
1887        Curve::Hyperbola(h) => {
1888            !h.is_reversed() && h.hyperbola().frame().handedness() == Handedness::Right
1889        }
1890        Curve::Offset(o) => curve_states_parameter(o.basis()),
1891        _ => false,
1892    }
1893}
1894
1895/// Whether a surface goes out in this kernel's own parameterization: the
1896/// elementary surfaces in right-handed frames, splines as themselves, and
1897/// sweeps and offsets of curves and surfaces that do.
1898fn surface_states_parameters(surface: &SurfaceGeometry) -> bool {
1899    let right = |frame: Frame| frame.handedness() == Handedness::Right;
1900    match surface {
1901        SurfaceGeometry::Plane(s) => right(s.plane().frame()),
1902        SurfaceGeometry::Cylinder(s) => right(s.cylinder().frame()),
1903        SurfaceGeometry::Cone(s) => right(s.cone().frame()),
1904        SurfaceGeometry::Sphere(s) => right(s.sphere().frame()),
1905        SurfaceGeometry::Torus(s) => right(s.torus().frame()),
1906        SurfaceGeometry::BSpline(_) => true,
1907        SurfaceGeometry::Revolution(r) => curve_states_parameter(r.curve()),
1908        SurfaceGeometry::Extrusion(e) => curve_states_parameter(e.curve()),
1909        SurfaceGeometry::Trimmed(t) => surface_states_parameters(t.basis()),
1910        SurfaceGeometry::Offset(o) => surface_states_parameters(o.basis()),
1911    }
1912}
1913
1914/// Whether a pcurve has an exact spelling in the file over its edge's
1915/// curve's parameter, `pace` relating the two: a line or a spline at any
1916/// pace, a circle or ellipse running forward in a right-handed frame at
1917/// the curve's own, or a forward trim of one of these, which states its
1918/// basis.
1919fn planar_stated(curve: &PlanarCurve, pace: Pace) -> bool {
1920    match curve {
1921        PlanarCurve::Line(_) | PlanarCurve::BSpline(_) => true,
1922        PlanarCurve::Circle(c) => {
1923            pace == Pace::SAME
1924                && !c.is_reversed()
1925                && c.circle().frame().handedness() == Handedness::Right
1926        }
1927        PlanarCurve::Ellipse(e) => {
1928            pace == Pace::SAME
1929                && !e.is_reversed()
1930                && e.ellipse().frame().handedness() == Handedness::Right
1931        }
1932        PlanarCurve::Trimmed(t) => !t.is_reversed() && planar_stated(t.basis(), pace),
1933        PlanarCurve::Hyperbola(_) | PlanarCurve::Offset(_) | PlanarCurve::Trig(_) => false,
1934    }
1935}
1936
1937/// A Part 21 real: shortest round-trip form, decimal point guaranteed,
1938/// exponent uppercased.
1939fn real(v: f64) -> String {
1940    if !v.is_finite() {
1941        NON_FINITE.with(|seen| seen.set(true));
1942        return "0.0".into();
1943    }
1944    let mut s = format!("{v:?}");
1945    if let Some(e) = s.find(['e', 'E']) {
1946        let (mantissa, exponent) = s.split_at(e);
1947        let mut m = mantissa.to_string();
1948        if !m.contains('.') {
1949            m.push_str(".0");
1950        }
1951        s = format!("{m}E{}", &exponent[1..]);
1952    } else if !s.contains('.') {
1953        s.push_str(".0");
1954    }
1955    s
1956}
1957
1958/// Entity ids as a Part 21 list's contents: `#1,#2,#3`.
1959fn reference_list(ids: &[u64]) -> String {
1960    ids.iter()
1961        .map(|i| format!("#{i}"))
1962        .collect::<Vec<_>>()
1963        .join(",")
1964}
1965
1966/// A string literal's body, quotes doubled per Part 21.
1967fn escape(s: &str) -> String {
1968    // Part 21 strings are ASCII: a quote is doubled, a backslash doubled,
1969    // and every run of characters past ASCII written as UTF-16 in hex
1970    // between `\X2\` and `\X0\`.
1971    let mut out = String::with_capacity(s.len());
1972    let mut wide: Vec<u16> = Vec::new();
1973    let flush = |wide: &mut Vec<u16>, out: &mut String| {
1974        if wide.is_empty() {
1975            return;
1976        }
1977        out.push_str("\\X2\\");
1978        for unit in wide.drain(..) {
1979            out.push_str(&format!("{unit:04X}"));
1980        }
1981        out.push_str("\\X0\\");
1982    };
1983    for c in s.chars() {
1984        if c.is_ascii() {
1985            flush(&mut wide, &mut out);
1986            match c {
1987                '\'' => out.push_str("''"),
1988                '\\' => out.push_str("\\\\"),
1989                _ => out.push(c),
1990            }
1991        } else {
1992            let mut units = [0_u16; 2];
1993            wide.extend_from_slice(c.encode_utf16(&mut units));
1994        }
1995    }
1996    flush(&mut wide, &mut out);
1997    out
1998}
1999
2000/// Knots as STEP states them: distinct values with multiplicities.
2001fn compress_knots(knots: &[f64]) -> (Vec<usize>, Vec<f64>) {
2002    let mut mults = Vec::new();
2003    let mut values = Vec::new();
2004    for &k in knots {
2005        match values.last() {
2006            Some(&last) if k == last => {
2007                if let Some(m) = mults.last_mut() {
2008                    *m += 1;
2009                }
2010            }
2011            _ => {
2012                values.push(k);
2013                mults.push(1);
2014            }
2015        }
2016    }
2017    (mults, values)
2018}
2019
2020/// A rigid transform quantized to bits, for per-placement deduplication.
2021fn transform_bits(t: &Transform) -> [u64; 3] {
2022    let p = t.apply(Point::new(0.123_456_789, 9.87, -3.21));
2023    [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
2024}
2025
2026/// A location resolved to the rigid transform it composes to.
2027fn location_transform(location: &ogeom_topo::Location, model: &Model) -> OgeomResult<Transform> {
2028    let mut out = Transform::IDENTITY;
2029    for &(datum, power) in location.chain() {
2030        let Some(t) = model.datums().get(datum) else {
2031            ogeom_bail!(Dangling, "an instance placement names a missing datum");
2032        };
2033        let step = if power >= 0 { t } else { t.inverse()? };
2034        for _ in 0..power.unsigned_abs() {
2035            out = out * step;
2036        }
2037    }
2038    Ok(out)
2039}
2040
2041#[cfg(test)]
2042mod escape_tests {
2043    /// Names past ASCII, quotes and backslashes survive a write and a read.
2044    #[test]
2045    fn strings_round_trip_through_part_21_escapes() {
2046        for name in [
2047            "Welle \u{f8}10",
2048            "\u{65e5}\u{672c}",
2049            "it's",
2050            "back\\slash",
2051            "emoji \u{1f600} end",
2052        ] {
2053            let written = super::escape(name);
2054            assert!(written.is_ascii(), "{written}");
2055            let read = super::super::parse::decode_escapes(&written.replace("''", "'"));
2056            assert_eq!(read, name, "{written}");
2057        }
2058        // Escapes as other exporters write them.
2059        assert_eq!(super::super::parse::decode_escapes("\\X\\F8"), "\u{f8}");
2060        assert_eq!(super::super::parse::decode_escapes("a\\S\\xb"), "a\u{f8}b");
2061        assert_eq!(super::super::parse::decode_escapes("\\PA\\ok"), "ok");
2062    }
2063}