Skip to main content

ogeom_io/step/
write.rs

1//! Writing STEP: the document's products, assemblies, colours and B-rep.
2//!
3//! The mirror of the reader, and deliberately written against the same
4//! vocabulary: every entity this writer emits is one the reader parses, so
5//! writing what was read and reading it back is the honest round-trip test.
6//! AP214's schema name goes in the header; the entities used are the common
7//! AP203/AP214/AP242 core.
8//!
9//! Geometry is written in world coordinates: every face, edge and vertex is
10//! transformed through its occurrence's own placement chain before it is
11//! serialized, and shared nodes are deduplicated *per placement*: a prism's
12//! bottom and top edge are one node at two locations, and the file needs
13//! both. Surfaces the format has no analytic name for (extrusions,
14//! revolutions) go out as their exact rational B-spline patches (§3's
15//! conversion), so nothing is fitted on the way out.
16
17use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
18use ogeom_doc::{Document, ProductId, ProductKind};
19use ogeom_geom::Transformable as _;
20use ogeom_geom::{Curve, SurfaceGeometry};
21use ogeom_math::{Frame, Point, Transform, Vector};
22use ogeom_topo::{EdgeRepr, Filter, Model, NodeData, Shape, ShapeType, explore};
23use std::collections::HashMap;
24use std::fmt::Write as _;
25
26/// Write a document as a STEP exchange file.
27///
28/// Products become `PRODUCT` trees; parts carry their solids as
29/// `MANIFOLD_SOLID_BREP`s; assemblies become usage occurrences with their
30/// placements; colours become styled items over the written solids and
31/// faces.
32///
33/// # Errors
34///
35/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
36/// shape's structure cannot be expressed: a non-rigid instance placement, a
37/// solid with no shell.
38pub fn write_step(document: &Document, tol: Tolerances) -> OgeomResult<String> {
39    let mut writer = Writer {
40        model: document.model(),
41        entities: Vec::new(),
42        points: HashMap::new(),
43        directions: HashMap::new(),
44        vertices: HashMap::new(),
45        edges: HashMap::new(),
46        written_nodes: Vec::new(),
47        tol,
48    };
49
50    let app = writer.entity("APPLICATION_CONTEXT('automotive design')".into());
51    let _proto = writer.entity(format!(
52        "APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2010,#{app})"
53    ));
54    let lu = writer.entity("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))".into());
55    let au = writer.entity("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))".into());
56    let su = writer.entity("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())".into());
57    let unc = writer.entity(format!(
58        "UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.0E-06),#{lu},'distance_accuracy_value','')"
59    ));
60    let gctx = writer.entity(format!(
61        "(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{lu},#{au},#{su}))REPRESENTATION_CONTEXT('Context','3D'))"
62    ));
63    let pctx = writer.entity(format!("PRODUCT_CONTEXT('',#{app},'mechanical')"));
64    let pdctx = writer.entity(format!(
65        "PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')"
66    ));
67
68    // Products, in document order; every product gets its definition and its
69    // shape representation before the assembly edges tie them together.
70    let mut pd_of: HashMap<ProductId, u64> = HashMap::new();
71    let mut sr_of: HashMap<ProductId, u64> = HashMap::new();
72    let mut anchor_pds: Option<(u64, u64)> = None;
73    for (id, product) in document.products() {
74        let name = escape(&product.name);
75        let p = writer.entity(format!("PRODUCT('{name}','{name}','',(#{pctx}))"));
76        let formation = writer.entity(format!("PRODUCT_DEFINITION_FORMATION('','',#{p})"));
77        let pd = writer.entity(format!(
78            "PRODUCT_DEFINITION('design','',#{formation},#{pdctx})"
79        ));
80        pd_of.insert(id, pd);
81
82        let world = writer.frame(&Frame::WORLD);
83        let sr = match &product.kind {
84            ProductKind::Part { shape } => {
85                let mut items = vec![world];
86                for solid in writer.solids_of(shape)? {
87                    items.push(solid);
88                }
89                let list = items
90                    .iter()
91                    .map(|i| format!("#{i}"))
92                    .collect::<Vec<_>>()
93                    .join(",");
94                writer.entity(format!(
95                    "ADVANCED_BREP_SHAPE_REPRESENTATION('{name}',({list}),#{gctx})"
96                ))
97            }
98            ProductKind::Assembly { .. } => {
99                writer.entity(format!("SHAPE_REPRESENTATION('{name}',(#{world}),#{gctx})"))
100            }
101        };
102        sr_of.insert(id, sr);
103        let pds = writer.entity(format!("PRODUCT_DEFINITION_SHAPE('','',#{pd})"));
104        writer.entity(format!("SHAPE_DEFINITION_REPRESENTATION(#{pds},#{sr})"));
105        if anchor_pds.is_none() && matches!(product.kind, ProductKind::Part { .. }) {
106            anchor_pds = Some((pds, sr));
107        }
108    }
109
110    // Assembly edges: one usage occurrence per instance, its placement said
111    // through the transformation between the parent's world frame and the
112    // child's placement frame.
113    let mut usage = 0_usize;
114    for (id, product) in document.products() {
115        let ProductKind::Assembly { children } = &product.kind else {
116            continue;
117        };
118        for instance in children {
119            usage += 1;
120            let designator = instance
121                .name
122                .clone()
123                .unwrap_or_else(|| format!("occurrence-{usage}"));
124            let designator = escape(&designator);
125            let (parent_pd, child_pd) = (pd_of[&id], pd_of[&instance.product]);
126            let (parent_sr, child_sr) = (sr_of[&id], sr_of[&instance.product]);
127            let nauo = writer.entity(format!(
128                "NEXT_ASSEMBLY_USAGE_OCCURRENCE('{designator}','{designator}','',#{parent_pd},#{child_pd},$)"
129            ));
130            // The location resolved through the model's own datum store: a
131            // placed dummy shape shares the resolution path every traversal
132            // uses.
133            let at = location_transform(&instance.location, document.model())?;
134            let placed = writer.placement_frame(&at)?;
135            let world = writer.frame(&Frame::WORLD);
136            let idt = writer.entity(format!(
137                "ITEM_DEFINED_TRANSFORMATION('','',#{world},#{placed})"
138            ));
139            let rr = writer.entity(format!(
140                "(REPRESENTATION_RELATIONSHIP('','',#{child_sr},#{parent_sr})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{idt})SHAPE_REPRESENTATION_RELATIONSHIP())"
141            ));
142            let pds = writer.entity(format!("PRODUCT_DEFINITION_SHAPE('','',#{nauo})"));
143            writer.entity(format!(
144                "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{rr},#{pds})"
145            ));
146        }
147    }
148
149    // Colours: a styled item over every written entity whose node the
150    // document colours, plus product colours carried by their solids.
151    let mut styled = Vec::new();
152    let node_colours: HashMap<_, _> = document.colours().collect();
153    for (node, step_id) in writer.written_nodes.clone() {
154        if let Some(colour) = node_colours.get(&node) {
155            styled.push(writer.styled_item(step_id, *colour));
156        }
157    }
158    for (id, product) in document.products() {
159        let Some(colour) = product.colour else {
160            continue;
161        };
162        let ProductKind::Part { shape } = &product.kind else {
163            continue;
164        };
165        let _ = id;
166        for (node, step_id) in writer.written_nodes.clone() {
167            if node == shape.node() && !node_colours.contains_key(&node) {
168                styled.push(writer.styled_item(step_id, colour));
169            }
170        }
171    }
172    if !styled.is_empty() {
173        let list = styled
174            .iter()
175            .map(|i| format!("#{i}"))
176            .collect::<Vec<_>>()
177            .join(",");
178        writer.entity(format!(
179            "MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',({list}),#{gctx})"
180        ));
181    }
182
183    // Semantic PMI: datums first, so tolerances can reference their letters.
184    let pmi = document.pmi();
185    if !pmi.is_empty() {
186        let Some((pds, absr)) = anchor_pds else {
187            ogeom_bail!(
188                Construction,
189                "PMI needs at least one part to anchor its aspects to"
190            );
191        };
192        writer.pmi(pmi, document.views(), pds, absr, lu, au, gctx)?;
193    }
194
195    let mut out = String::new();
196    out.push_str("ISO-10303-21;\nHEADER;\n");
197    out.push_str("FILE_DESCRIPTION(('written by ogeom'),'2;1');\n");
198    out.push_str("FILE_NAME('','',('ogeom'),('ogeom'),'ogeom','ogeom','');\n");
199    out.push_str("FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\n");
200    out.push_str("ENDSEC;\nDATA;\n");
201    for (i, entity) in writer.entities.iter().enumerate() {
202        let _ = writeln!(out, "#{}={entity};", i + 1);
203    }
204    out.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
205    Ok(out)
206}
207
208/// The state of one write: the entity buffer and the per-placement caches.
209struct Writer<'a> {
210    model: &'a Model,
211    entities: Vec<String>,
212    points: HashMap<[u64; 3], u64>,
213    directions: HashMap<[u64; 3], u64>,
214    /// Vertex occurrences by node and world position bits.
215    vertices: HashMap<(ogeom_topo::TShapeId, [u64; 3]), u64>,
216    /// Edge occurrences by node and placement bits.
217    edges: HashMap<(ogeom_topo::TShapeId, [u64; 3]), u64>,
218    /// Every solid and face written, with its entity id: the hooks colours
219    /// attach to.
220    written_nodes: Vec<(ogeom_topo::TShapeId, u64)>,
221    tol: Tolerances,
222}
223
224impl Writer<'_> {
225    fn entity(&mut self, text: String) -> u64 {
226        self.entities.push(text);
227        self.entities.len() as u64
228    }
229
230    fn point(&mut self, p: Point) -> u64 {
231        let key = [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()];
232        if let Some(&id) = self.points.get(&key) {
233            return id;
234        }
235        let id = self.entity(format!(
236            "CARTESIAN_POINT('',({},{},{}))",
237            real(p.x),
238            real(p.y),
239            real(p.z)
240        ));
241        self.points.insert(key, id);
242        id
243    }
244
245    fn direction(&mut self, v: Vector) -> u64 {
246        let key = [v.x.to_bits(), v.y.to_bits(), v.z.to_bits()];
247        if let Some(&id) = self.directions.get(&key) {
248            return id;
249        }
250        let id = self.entity(format!(
251            "DIRECTION('',({},{},{}))",
252            real(v.x),
253            real(v.y),
254            real(v.z)
255        ));
256        self.directions.insert(key, id);
257        id
258    }
259
260    fn frame(&mut self, frame: &Frame) -> u64 {
261        let origin = self.point(frame.origin());
262        let z = self.direction(frame.z().vector());
263        let x = self.direction(frame.x().vector());
264        self.entity(format!("AXIS2_PLACEMENT_3D('',#{origin},#{z},#{x})"))
265    }
266
267    /// A rigid transform as the frame it carries the world onto.
268    fn placement_frame(&mut self, at: &Transform) -> OgeomResult<u64> {
269        let origin = at.apply(Point::ORIGIN);
270        let z = at.apply_vector(Vector::new(0.0, 0.0, 1.0));
271        let x = at.apply_vector(Vector::new(1.0, 0.0, 0.0));
272        let frame = Frame::new(
273            origin,
274            ogeom_math::Direction::new(z, self.tol)?,
275            ogeom_math::Direction::new(x, self.tol)?,
276            self.tol,
277        )
278        .map_err(|_| {
279            ogeom_core::ogeom_err!(
280                Construction,
281                "an instance placement is not rigid; STEP cannot state it"
282            )
283        })?;
284        Ok(self.frame(&frame))
285    }
286
287    /// Every solid under a part's shape, written.
288    fn solids_of(&mut self, shape: &Shape) -> OgeomResult<Vec<u64>> {
289        let mut out = Vec::new();
290        for solid in explore(self.model, shape, Filter::OfType(ShapeType::Solid))? {
291            out.push(self.solid(&solid)?);
292        }
293        if out.is_empty() {
294            ogeom_bail!(Construction, "a part's shape holds no solid to write");
295        }
296        Ok(out)
297    }
298
299    fn solid(&mut self, solid: &Shape) -> OgeomResult<u64> {
300        let shells = explore(self.model, solid, Filter::OfType(ShapeType::Shell))?;
301        let Some(shell) = shells.first() else {
302            ogeom_bail!(Construction, "a solid with no shell cannot be written");
303        };
304        let mut faces = Vec::new();
305        for face in self.model.ordered_children_of(shell)? {
306            faces.push(self.face(&face)?);
307        }
308        let list = faces
309            .iter()
310            .map(|i| format!("#{i}"))
311            .collect::<Vec<_>>()
312            .join(",");
313        let shell_id = self.entity(format!("CLOSED_SHELL('',({list}))"));
314        let msb = self.entity(format!("MANIFOLD_SOLID_BREP('',#{shell_id})"));
315        self.written_nodes.push((solid.node(), msb));
316        Ok(msb)
317    }
318
319    fn face(&mut self, face: &Shape) -> OgeomResult<u64> {
320        let placement = face.transform(self.model.datums())?;
321        let surface = {
322            let Some(node) = self.model.node(face) else {
323                ogeom_bail!(Dangling, "face is not in this model");
324            };
325            let NodeData::Face(data) = node.data() else {
326                ogeom_bail!(Construction, "face node holds no face data");
327            };
328            let Some(surface) = self.model.geometry().surface(data.surface) else {
329                ogeom_bail!(Dangling, "face refers to a surface not in this model");
330            };
331            surface.clone().transformed(&placement, self.tol)?
332        };
333        let surface_id = self.surface(&surface)?;
334
335        let mut bounds = Vec::new();
336        for (index, wire) in self.model.ordered_children_of(face)?.iter().enumerate() {
337            let keyword = if index == 0 {
338                "FACE_OUTER_BOUND"
339            } else {
340                "FACE_BOUND"
341            };
342            let loop_id = self.wire(wire)?;
343            bounds.push(self.entity(format!("{keyword}('',#{loop_id},.T.)")));
344        }
345        let list = bounds
346            .iter()
347            .map(|i| format!("#{i}"))
348            .collect::<Vec<_>>()
349            .join(",");
350        let sense = if face.orientation() == ogeom_topo::Orientation::Reversed {
351            ".F."
352        } else {
353            ".T."
354        };
355        let id = self.entity(format!("ADVANCED_FACE('',({list}),#{surface_id},{sense})"));
356        self.written_nodes.push((face.node(), id));
357        Ok(id)
358    }
359
360    /// A wire as an `EDGE_LOOP`, or a `VERTEX_LOOP` when every edge in it is
361    /// degenerate: a pole or an apex has no curve to serialize, and STEP's
362    /// own spelling for it is the loop of one vertex.
363    fn wire(&mut self, wire: &Shape) -> OgeomResult<u64> {
364        let children = self.model.ordered_children_of(wire)?;
365        let degenerate = |edge: &Shape| {
366            self.model
367                .node(edge)
368                .and_then(|n| n.data().as_edge())
369                .is_some_and(|d| d.degenerate)
370        };
371        if !children.is_empty() && children.iter().all(degenerate) {
372            let edge = &children[0];
373            let Some(vertex) = self.model.children_of(edge)?.first().cloned() else {
374                ogeom_bail!(Construction, "a degenerate edge has no vertex");
375            };
376            let vertex_id = self.vertex(&vertex, &edge.transform(self.model.datums())?)?;
377            return Ok(self.entity(format!("VERTEX_LOOP('',#{vertex_id})")));
378        }
379        let mut oriented = Vec::new();
380        for edge in &children {
381            if degenerate(edge) {
382                continue;
383            }
384            let edge_id = self.edge(edge)?;
385            let sense = if edge.orientation() == ogeom_topo::Orientation::Reversed {
386                ".F."
387            } else {
388                ".T."
389            };
390            oriented.push(self.entity(format!("ORIENTED_EDGE('',*,*,#{edge_id},{sense})")));
391        }
392        let list = oriented
393            .iter()
394            .map(|i| format!("#{i}"))
395            .collect::<Vec<_>>()
396            .join(",");
397        Ok(self.entity(format!("EDGE_LOOP('',({list}))")))
398    }
399
400    fn edge(&mut self, edge: &Shape) -> OgeomResult<u64> {
401        let placement = edge.transform(self.model.datums())?;
402        let key = (edge.node(), transform_bits(&placement));
403        if let Some(&id) = self.edges.get(&key) {
404            return Ok(id);
405        }
406        let (curve, range) = {
407            let Some(node) = self.model.node(edge) else {
408                ogeom_bail!(Dangling, "edge is not in this model");
409            };
410            let Some(data) = node.data().as_edge() else {
411                ogeom_bail!(Construction, "edge node holds no edge data");
412            };
413            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
414                ogeom_bail!(Construction, "an edge with no curve cannot be written");
415            };
416            let Some(geometry) = self.model.geometry().curve(*curve) else {
417                ogeom_bail!(Dangling, "curve is not in this model");
418            };
419            (geometry.clone().transformed(&placement, self.tol)?, *range)
420        };
421        let curve_id = self.curve(&curve, range)?;
422        let vertices = self.model.children_of(edge)?;
423        let (from, to) = match vertices.len() {
424            0 => ogeom_bail!(Construction, "an edge with no vertices cannot be written"),
425            1 => (vertices[0].clone(), vertices[0].clone()),
426            _ => (vertices[0].clone(), vertices[vertices.len() - 1].clone()),
427        };
428        // Each vertex's own composed placement, not the edge's: children_of
429        // already folds the edge's chain in, and an instanced vertex adds a
430        // hop of its own that the edge's placement alone would drop.
431        let from_placement = from.transform(self.model.datums())?;
432        let to_placement = to.transform(self.model.datums())?;
433        let from_id = self.vertex(&from, &from_placement)?;
434        let to_id = self.vertex(&to, &to_placement)?;
435        let id = self.entity(format!(
436            "EDGE_CURVE('',#{from_id},#{to_id},#{curve_id},.T.)"
437        ));
438        self.edges.insert(key, id);
439        Ok(id)
440    }
441
442    fn vertex(&mut self, vertex: &Shape, placement: &Transform) -> OgeomResult<u64> {
443        let Some(data) = self.model.node(vertex).and_then(|n| n.data().as_vertex()) else {
444            ogeom_bail!(Construction, "vertex node holds no vertex data");
445        };
446        let at = placement.apply(data.point);
447        let key = (
448            vertex.node(),
449            [at.x.to_bits(), at.y.to_bits(), at.z.to_bits()],
450        );
451        if let Some(&id) = self.vertices.get(&key) {
452            return Ok(id);
453        }
454        let point = self.point(at);
455        let id = self.entity(format!("VERTEX_POINT('',#{point})"));
456        self.vertices.insert(key, id);
457        Ok(id)
458    }
459
460    /// A curve for an `EDGE_CURVE`: the analytic spelling where STEP has
461    /// one, the exact B-spline conversion where it does not.
462    fn curve(&mut self, curve: &Curve, range: (f64, f64)) -> OgeomResult<u64> {
463        match curve {
464            Curve::Line(line) => {
465                let axis = line.axis();
466                let origin = self.point(axis.location);
467                let d = self.direction(axis.direction.vector());
468                let vector = self.entity(format!("VECTOR('',#{d},1.0)"));
469                Ok(self.entity(format!("LINE('',#{origin},#{vector})")))
470            }
471            Curve::Circle(c) => {
472                let circle = c.circle();
473                let frame = self.frame(&circle.frame());
474                Ok(self.entity(format!("CIRCLE('',#{frame},{})", real(circle.radius()))))
475            }
476            Curve::Ellipse(el) => {
477                let ellipse = el.ellipse();
478                let frame = self.frame(&ellipse.frame());
479                Ok(self.entity(format!(
480                    "ELLIPSE('',#{frame},{},{})",
481                    real(ellipse.major_radius()),
482                    real(ellipse.minor_radius())
483                )))
484            }
485            Curve::BSpline(b) => self.bspline_curve(b),
486            // The open conics in STEP's own spelling, where their parameter
487            // runs STEP's way; a reversed one is written as its spline.
488            Curve::Hyperbola(h) if !h.is_reversed() => {
489                let hyperbola = h.hyperbola();
490                let frame = self.frame(&hyperbola.frame());
491                Ok(self.entity(format!(
492                    "HYPERBOLA('',#{frame},{},{})",
493                    real(hyperbola.major_radius()),
494                    real(hyperbola.minor_radius())
495                )))
496            }
497            Curve::Parabola(p) if !p.is_reversed() => {
498                let parabola = p.parabola();
499                let frame = self.frame(&parabola.frame());
500                Ok(self.entity(format!("PARABOLA('',#{frame},{})", real(parabola.focal()))))
501            }
502            Curve::Offset(o) => {
503                let basis = self.curve(o.basis(), range)?;
504                let reference = self.direction(o.reference().vector());
505                Ok(self.entity(format!(
506                    "OFFSET_CURVE_3D('',#{basis},{},.F.,#{reference})",
507                    real(o.distance())
508                )))
509            }
510            other => {
511                // Exact for the conics and trims: the conversion is the §3
512                // machinery, not a fit. A helix refuses inside the
513                // conversion: it has no exact spline form, and writing a
514                // fit without saying so is the lie this crate does not tell.
515                let spline = other.to_bspline_over(range, self.tol)?;
516                self.bspline_curve(&spline)
517            }
518        }
519    }
520
521    fn bspline_curve(&mut self, spline: &ogeom_geom::BSplineCurve) -> OgeomResult<u64> {
522        let control: Vec<String> = spline
523            .control_points()
524            .iter()
525            .map(|c| {
526                let p = Point::from_vector(c.scaled.to_vector() / c.weight);
527                format!("#{}", self.point(p))
528            })
529            .collect();
530        let (mults, knots) = compress_knots(spline.knots().knots());
531        let degree = spline.knots().degree();
532        let rational = spline
533            .control_points()
534            .iter()
535            .any(|c| (c.weight - 1.0).abs() > 1e-12);
536        let control = control.join(",");
537        let mults = mults
538            .iter()
539            .map(ToString::to_string)
540            .collect::<Vec<_>>()
541            .join(",");
542        let knots = knots.iter().map(|k| real(*k)).collect::<Vec<_>>().join(",");
543        if rational {
544            let weights = spline
545                .control_points()
546                .iter()
547                .map(|c| real(c.weight))
548                .collect::<Vec<_>>()
549                .join(",");
550            Ok(self.entity(format!(
551                "(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(''))"
552            )))
553        } else {
554            Ok(self.entity(format!(
555                "B_SPLINE_CURVE_WITH_KNOTS('',{degree},({control}),.UNSPECIFIED.,.F.,.F.,({mults}),({knots}),.UNSPECIFIED.)"
556            )))
557        }
558    }
559
560    /// A surface: analytic where STEP has the word, the exact B-spline patch
561    /// where it does not. Trimmed surfaces write their basis; the trim is
562    /// the face's own topology.
563    fn surface(&mut self, surface: &SurfaceGeometry) -> OgeomResult<u64> {
564        match surface {
565            SurfaceGeometry::Plane(s) => {
566                let frame = self.frame(&s.plane().frame());
567                Ok(self.entity(format!("PLANE('',#{frame})")))
568            }
569            SurfaceGeometry::Cylinder(s) => {
570                let cylinder = s.cylinder();
571                let frame = self.frame(&cylinder.frame());
572                Ok(self.entity(format!(
573                    "CYLINDRICAL_SURFACE('',#{frame},{})",
574                    real(cylinder.radius())
575                )))
576            }
577            SurfaceGeometry::Cone(s) => {
578                let cone = s.cone();
579                let frame = self.frame(&cone.frame());
580                Ok(self.entity(format!(
581                    "CONICAL_SURFACE('',#{frame},{},{})",
582                    real(cone.radius_at(0.0)),
583                    real(cone.half_angle())
584                )))
585            }
586            SurfaceGeometry::Sphere(s) => {
587                let sphere = s.sphere();
588                let frame = self.frame(&sphere.frame());
589                Ok(self.entity(format!(
590                    "SPHERICAL_SURFACE('',#{frame},{})",
591                    real(sphere.radius())
592                )))
593            }
594            SurfaceGeometry::Torus(s) => {
595                let torus = s.torus();
596                let frame = self.frame(&torus.frame());
597                Ok(self.entity(format!(
598                    "TOROIDAL_SURFACE('',#{frame},{},{})",
599                    real(torus.major_radius()),
600                    real(torus.minor_radius())
601                )))
602            }
603            SurfaceGeometry::BSpline(b) => self.bspline_surface(b),
604            SurfaceGeometry::Trimmed(t) => self.surface(t.basis()),
605            // Swept and offset surfaces in STEP's own spelling: exact, and
606            // read back as themselves.
607            SurfaceGeometry::Revolution(r) => {
608                let curve = r.curve();
609                let range = ogeom_geom::Curve3d::domain(curve);
610                let swept = self.curve(curve, range)?;
611                let axis = r.axis();
612                let location = self.point(axis.location);
613                let direction = self.direction(axis.direction.vector());
614                let placement =
615                    self.entity(format!("AXIS1_PLACEMENT('',#{location},#{direction})"));
616                Ok(self.entity(format!("SURFACE_OF_REVOLUTION('',#{swept},#{placement})")))
617            }
618            SurfaceGeometry::Extrusion(e) => {
619                let curve = e.curve();
620                let range = ogeom_geom::Curve3d::domain(curve);
621                let swept = self.curve(curve, range)?;
622                let direction = self.direction(e.direction().vector());
623                let vector = self.entity(format!("VECTOR('',#{direction},1.0)"));
624                Ok(self.entity(format!(
625                    "SURFACE_OF_LINEAR_EXTRUSION('',#{swept},#{vector})"
626                )))
627            }
628            SurfaceGeometry::Offset(o) => {
629                let basis = self.surface(o.basis())?;
630                Ok(self.entity(format!(
631                    "OFFSET_SURFACE('',#{basis},{},.F.)",
632                    real(o.distance())
633                )))
634            }
635        }
636    }
637
638    fn bspline_surface(&mut self, patch: &ogeom_geom::BSplineSurface) -> OgeomResult<u64> {
639        let grid = patch.grid();
640        let (nu, nv) = (grid.u_count(), grid.v_count());
641        let mut rows = Vec::with_capacity(nu);
642        let mut weights_rows = Vec::with_capacity(nu);
643        let mut rational = false;
644        for u in 0..nu {
645            let mut row = Vec::with_capacity(nv);
646            let mut wrow = Vec::with_capacity(nv);
647            for v in 0..nv {
648                let Some(c) = grid.get(u, v) else {
649                    ogeom_bail!(Construction, "a control grid cell is missing");
650                };
651                let p = Point::from_vector(c.scaled.to_vector() / c.weight);
652                row.push(format!("#{}", self.point(p)));
653                wrow.push(real(c.weight));
654                rational |= (c.weight - 1.0).abs() > 1e-12;
655            }
656            rows.push(format!("({})", row.join(",")));
657            weights_rows.push(format!("({})", wrow.join(",")));
658        }
659        let grid_text = rows.join(",");
660        let (u_deg, v_deg) = (patch.u_knots().degree(), patch.v_knots().degree());
661        let (um, uk) = compress_knots(patch.u_knots().knots());
662        let (vm, vk) = compress_knots(patch.v_knots().knots());
663        let fmt_m = |m: &[usize]| {
664            m.iter()
665                .map(ToString::to_string)
666                .collect::<Vec<_>>()
667                .join(",")
668        };
669        let fmt_k = |k: &[f64]| k.iter().map(|v| real(*v)).collect::<Vec<_>>().join(",");
670        let (um, uk, vm, vk) = (fmt_m(&um), fmt_k(&uk), fmt_m(&vm), fmt_k(&vk));
671        if rational {
672            let weights = weights_rows.join(",");
673            Ok(self.entity(format!(
674                "(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())"
675            )))
676        } else {
677            Ok(self.entity(format!(
678                "B_SPLINE_SURFACE_WITH_KNOTS('',{u_deg},{v_deg},({grid_text}),.UNSPECIFIED.,.F.,.F.,.F.,({um}),({vm}),({uk}),({vk}),.UNSPECIFIED.)"
679            )))
680        }
681    }
682
683    /// The document's PMI, written over the aspects of the anchor part.
684    #[allow(clippy::many_single_char_names)]
685    #[allow(
686        clippy::too_many_arguments,
687        reason = "five STEP context ids that travel together"
688    )]
689    fn pmi(
690        &mut self,
691        pmi: &ogeom_doc::Pmi,
692        views: &[ogeom_doc::View],
693        pds: u64,
694        absr: u64,
695        lu: u64,
696        au: u64,
697        gctx: u64,
698    ) -> OgeomResult<()> {
699        let by_node: HashMap<ogeom_topo::TShapeId, u64> =
700            self.written_nodes.iter().copied().collect();
701        let aspect_for = |w: &mut Self, items: &[ogeom_topo::TShapeId]| -> u64 {
702            let aspect = w.entity(format!("SHAPE_ASPECT('','',#{pds},.T.)"));
703            for item in items {
704                if let Some(&step_id) = by_node.get(item) {
705                    w.entity(format!(
706                        "GEOMETRIC_ITEM_SPECIFIC_USAGE('','',#{aspect},#{absr},#{step_id})"
707                    ));
708                }
709            }
710            aspect
711        };
712
713        // Which STEP id each annotation was written as, so the presentation
714        // below can point a callout at the annotation it draws.
715        let mut annotation_ids: HashMap<ogeom_doc::Annotated, u64> = HashMap::new();
716        let mut datum_ids: HashMap<&str, u64> = HashMap::new();
717        for datum in &pmi.datums {
718            let label = escape(&datum.label);
719            let id = self.entity(format!("DATUM('','',#{pds},.F.,'{label}')"));
720            for item in &datum.items {
721                if let Some(&step_id) = by_node.get(item) {
722                    self.entity(format!(
723                        "GEOMETRIC_ITEM_SPECIFIC_USAGE('','',#{id},#{absr},#{step_id})"
724                    ));
725                }
726            }
727            datum_ids.insert(datum.label.as_str(), id);
728            annotation_ids.insert(ogeom_doc::Annotated::Datum(datum_ids.len() - 1), id);
729        }
730
731        for (at, dimension) in pmi.dimensions.iter().enumerate() {
732            let name = escape(&dimension.name);
733            let angular = dimension.kind == ogeom_doc::MeasureKind::Angle;
734            let dim = if dimension.location {
735                // A location runs between two features; each keeps its own
736                // aspect, so what was read as two ends writes as two ends.
737                let empty = Vec::new();
738                let first = dimension.features.first().unwrap_or(&empty);
739                let second = dimension.features.get(1).unwrap_or(first);
740                let a = aspect_for(self, first);
741                let b = aspect_for(self, second);
742                if angular {
743                    self.entity(format!("ANGULAR_LOCATION('{name}','',#{a},#{b},.EQUAL.)"))
744                } else {
745                    self.entity(format!("DIMENSIONAL_LOCATION('{name}','',#{a},#{b})"))
746                }
747            } else {
748                let empty = Vec::new();
749                let items = dimension.features.first().unwrap_or(&empty);
750                let aspect = aspect_for(self, items);
751                if angular {
752                    self.entity(format!("ANGULAR_SIZE(#{aspect},'{name}',.EQUAL.)"))
753                } else {
754                    self.entity(format!("DIMENSIONAL_SIZE(#{aspect},'{name}')"))
755                }
756            };
757            let measures: Vec<String> = dimension
758                .values
759                .iter()
760                .map(|&v| format!("#{}", self.measure(v, dimension.kind, lu, au)))
761                .collect();
762            let list = measures.join(",");
763            annotation_ids.insert(ogeom_doc::Annotated::Dimension(at), dim);
764            let sdr = self.entity(format!(
765                "SHAPE_DIMENSION_REPRESENTATION('',({list}),#{gctx})"
766            ));
767            self.entity(format!(
768                "DIMENSIONAL_CHARACTERISTIC_REPRESENTATION(#{dim},#{sdr})"
769            ));
770            if dimension.plus.is_some() || dimension.minus.is_some() {
771                let lower = self.measure(dimension.minus.unwrap_or(0.0), dimension.kind, lu, au);
772                let upper = self.measure(dimension.plus.unwrap_or(0.0), dimension.kind, lu, au);
773                let tv = self.entity(format!("TOLERANCE_VALUE(#{lower},#{upper})"));
774                self.entity(format!("PLUS_MINUS_TOLERANCE(#{tv},#{dim})"));
775            }
776        }
777
778        for (at, tolerance) in pmi.tolerances.iter().enumerate() {
779            let aspect = aspect_for(self, &tolerance.items);
780            let name = escape(&tolerance.name);
781            let magnitude =
782                self.measure(tolerance.magnitude, ogeom_doc::MeasureKind::Length, lu, au);
783            let keyword = format!("{}_TOLERANCE", tolerance.kind.to_uppercase());
784            // A hyphen-joined label is a composite reference: its
785            // constituent datums go through a compartment that binds them
786            // into one.
787            let refs: Vec<String> = tolerance
788                .datums
789                .iter()
790                .filter_map(|d| {
791                    if let Some(id) = datum_ids.get(d.as_str()) {
792                        return Some(format!("#{id}"));
793                    }
794                    let parts: Vec<String> = d
795                        .split('-')
796                        .filter_map(|label| datum_ids.get(label))
797                        .map(|i| format!("#{i}"))
798                        .collect();
799                    if parts.len() < 2 {
800                        return None;
801                    }
802                    let list = parts.join(",");
803                    let compartment = self.entity(format!(
804                        "DATUM_REFERENCE_COMPARTMENT('','',#{pds},.F.,({list}),())"
805                    ));
806                    Some(format!("#{compartment}"))
807                })
808                .collect();
809            let refs = refs.join(",");
810            let written = if tolerance.modifiers.is_empty() {
811                if refs.is_empty() {
812                    self.entity(format!("{keyword}('{name}','',#{magnitude},#{aspect})"))
813                } else {
814                    self.entity(format!(
815                        "{keyword}('{name}','',#{magnitude},#{aspect},({refs}))"
816                    ))
817                }
818            } else {
819                // Modifiers force the complex form: the shared attributes
820                // sit on the GEOMETRIC_TOLERANCE part, the datum list and
821                // the modifier words on their own parts, the subtype empty.
822                let words: Vec<String> = tolerance
823                    .modifiers
824                    .iter()
825                    .map(|m| format!(".{}.", m.to_uppercase()))
826                    .collect();
827                let words = words.join(",");
828                let mut parts = vec![
829                    format!("GEOMETRIC_TOLERANCE('{name}','',#{magnitude},#{aspect})"),
830                    format!("GEOMETRIC_TOLERANCE_WITH_MODIFIERS(({words}))"),
831                    format!("{keyword}()"),
832                ];
833                if !refs.is_empty() {
834                    parts.push(format!(
835                        "GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE(({refs}))"
836                    ));
837                }
838                parts.sort();
839                self.entity(format!("({})", parts.concat()))
840            };
841            annotation_ids.insert(ogeom_doc::Annotated::Tolerance(at), written);
842        }
843
844        // Datum targets: the pads a datum is established at. The identifier
845        // is the letter and the number the drawing shows (`A1`), and the
846        // placement and sizes go in a shape representation the target's own
847        // property definition names, which is where the reader looks for them.
848        for target in &pmi.targets {
849            let identifier = escape(&target.identifier());
850            let description = match target.kind {
851                ogeom_doc::DatumTargetKind::Point => "point",
852                ogeom_doc::DatumTargetKind::Line { .. } => "line",
853                ogeom_doc::DatumTargetKind::Rectangle { .. } => "rectangle",
854                ogeom_doc::DatumTargetKind::Circle { .. } => "circle",
855            };
856            let id = self.entity(format!(
857                "PLACED_DATUM_TARGET_FEATURE('','{description}',#{pds},.F.,'{identifier}')"
858            ));
859            for item in &target.items {
860                if let Some(&step_id) = by_node.get(item) {
861                    self.entity(format!(
862                        "GEOMETRIC_ITEM_SPECIFIC_USAGE('','',#{id},#{absr},#{step_id})"
863                    ));
864                }
865            }
866            // Tie the target to its own datum, so a reader that meets the
867            // target first can still say which datum it establishes.
868            if let Some(&datum_id) = datum_ids.get(target.datum.as_str()) {
869                self.entity(format!(
870                    "SHAPE_ASPECT_RELATIONSHIP('','',#{datum_id},#{id})"
871                ));
872            }
873            let frame = target
874                .frame
875                .unwrap_or_else(|| Frame::about(target.at, ogeom_math::Direction::Z));
876            let placement = self.frame(&frame);
877            let sizes: Vec<f64> = match target.kind {
878                ogeom_doc::DatumTargetKind::Point => Vec::new(),
879                ogeom_doc::DatumTargetKind::Line { length } => vec![length],
880                ogeom_doc::DatumTargetKind::Rectangle { length, width } => vec![length, width],
881                ogeom_doc::DatumTargetKind::Circle { diameter } => vec![diameter],
882            };
883            let mut items = vec![format!("#{placement}")];
884            for size in sizes {
885                let measure = self.measure(size, ogeom_doc::MeasureKind::Length, lu, au);
886                items.push(format!("#{measure}"));
887            }
888            let list = items.join(",");
889            let rep = self.entity(format!(
890                "SHAPE_REPRESENTATION('{identifier}',({list}),#{gctx})"
891            ));
892            let property = self.entity(format!("PROPERTY_DEFINITION('','',#{id})"));
893            self.entity(format!(
894                "SHAPE_DEFINITION_REPRESENTATION(#{property},#{rep})"
895            ));
896        }
897
898        // Presentation: the drawn annotations. One curve set per callout over
899        // one coordinates list, held by an occurrence, held by the callout;
900        // the plane it is drawn in; and the association that says which
901        // semantic annotation it is a picture of.
902        let mut callout_ids: Vec<u64> = Vec::new();
903        if !pmi.callouts.is_empty() {
904            let mut drawn: Vec<(u64, ogeom_doc::Annotated)> = Vec::new();
905            let mut planes: Vec<String> = Vec::new();
906            for callout in &pmi.callouts {
907                let name = escape(&callout.name);
908                let mut coordinates: Vec<String> = Vec::new();
909                let mut lines: Vec<String> = Vec::new();
910                let mut next = 1_usize;
911                for polyline in &callout.polylines {
912                    let mut indices: Vec<String> = Vec::with_capacity(polyline.len());
913                    for p in polyline {
914                        coordinates.push(format!("({},{},{})", real(p.x), real(p.y), real(p.z)));
915                        indices.push(next.to_string());
916                        next += 1;
917                    }
918                    lines.push(format!("({})", indices.join(",")));
919                }
920                let count = coordinates.len();
921                let coordinates = coordinates.join(",");
922                let list = self.entity(format!(
923                    "COORDINATES_LIST('{name}',{count},({coordinates}))"
924                ));
925                let lines = lines.join(",");
926                let set = self.entity(format!("TESSELLATED_CURVE_SET('{name}',#{list},({lines}))"));
927                // No style is written, and that is a statement rather than an
928                // omission: a style is about rendering, and this kernel keeps
929                // no draughting style model to have one from.
930                let occurrence = self.entity(format!(
931                    "TESSELLATED_ANNOTATION_OCCURRENCE('{name}',(),#{set})"
932                ));
933                let id = self.entity(format!("DRAUGHTING_CALLOUT('{name}',(#{occurrence}))"));
934                callout_ids.push(id);
935                if let Some(frame) = callout.plane {
936                    let placement = self.frame(&frame);
937                    let plane = self.entity(format!("PLANE('{name}',#{placement})"));
938                    planes.push(
939                        self.entity(format!("ANNOTATION_PLANE('{name}',(),#{plane},(#{id}))"))
940                            .to_string(),
941                    );
942                }
943                if let Some(annotates) = callout.annotates {
944                    drawn.push((id, annotates));
945                }
946            }
947            let list: Vec<String> = planes.iter().map(|p| format!("#{p}")).collect();
948            let list = list.join(",");
949            let model = self.entity(format!("DRAUGHTING_MODEL('',({list}),#{gctx})"));
950            for (callout, annotates) in drawn {
951                let Some(&annotation) = annotation_ids.get(&annotates) else {
952                    continue;
953                };
954                self.entity(format!(
955                    "DRAUGHTING_MODEL_ITEM_ASSOCIATION('PMI representation to presentation \
956                     link','',#{annotation},#{model},#{callout})"
957                ));
958            }
959        }
960
961        // Saved views: a named draughting model per view, holding a camera
962        // and the callouts the view presents. The camera's view volume is
963        // written `$`: this writer keeps no viewing frustum to state, and
964        // the readers that matter (this one included) take the name and
965        // the placement and leave the rest.
966        for view in views {
967            let name = escape(&view.name);
968            let placement = self.frame(&view.frame);
969            let camera = self.entity(format!("CAMERA_MODEL_D3('{name}',#{placement},$)"));
970            let mut items = vec![format!("#{camera}")];
971            for &index in &view.callouts {
972                if let Some(id) = callout_ids.get(index) {
973                    items.push(format!("#{id}"));
974                }
975            }
976            let items = items.join(",");
977            self.entity(format!("DRAUGHTING_MODEL('{name}',({items}),#{gctx})"));
978        }
979        Ok(())
980    }
981
982    /// A measure representation item, in the document's own units.
983    fn measure(&mut self, value: f64, kind: ogeom_doc::MeasureKind, lu: u64, au: u64) -> u64 {
984        let v = real(value);
985        match kind {
986            ogeom_doc::MeasureKind::Length => self.entity(format!(
987                "(LENGTH_MEASURE_WITH_UNIT()MEASURE_REPRESENTATION_ITEM()MEASURE_WITH_UNIT(LENGTH_MEASURE({v}),#{lu})REPRESENTATION_ITEM(''))"
988            )),
989            ogeom_doc::MeasureKind::Angle => self.entity(format!(
990                "(MEASURE_REPRESENTATION_ITEM()MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE({v}),#{au})PLANE_ANGLE_MEASURE_WITH_UNIT()REPRESENTATION_ITEM(''))"
991            )),
992        }
993    }
994
995    /// A styled item colouring one written entity.
996    fn styled_item(&mut self, item: u64, colour: ogeom_doc::Colour) -> u64 {
997        let rgb = self.entity(format!(
998            "COLOUR_RGB('',{},{},{})",
999            real(colour.r),
1000            real(colour.g),
1001            real(colour.b)
1002        ));
1003        let fasc = self.entity(format!("FILL_AREA_STYLE_COLOUR('',#{rgb})"));
1004        let fas = self.entity(format!("FILL_AREA_STYLE('',(#{fasc}))"));
1005        let ssfa = self.entity(format!("SURFACE_STYLE_FILL_AREA(#{fas})"));
1006        let sss = self.entity(format!("SURFACE_SIDE_STYLE('',(#{ssfa}))"));
1007        let ssu = self.entity(format!("SURFACE_STYLE_USAGE(.BOTH.,#{sss})"));
1008        let psa = self.entity(format!("PRESENTATION_STYLE_ASSIGNMENT((#{ssu}))"));
1009        self.entity(format!("STYLED_ITEM('',(#{psa}),#{item})"))
1010    }
1011}
1012
1013/// A Part 21 real: shortest round-trip form, decimal point guaranteed,
1014/// exponent uppercased.
1015fn real(v: f64) -> String {
1016    let mut s = format!("{v:?}");
1017    if let Some(e) = s.find(['e', 'E']) {
1018        let (mantissa, exponent) = s.split_at(e);
1019        let mut m = mantissa.to_string();
1020        if !m.contains('.') {
1021            m.push_str(".0");
1022        }
1023        s = format!("{m}E{}", &exponent[1..]);
1024    } else if !s.contains('.') {
1025        s.push_str(".0");
1026    }
1027    s
1028}
1029
1030/// A string literal's body, quotes doubled per Part 21.
1031fn escape(s: &str) -> String {
1032    s.replace('\'', "''")
1033}
1034
1035/// Knots as STEP states them: distinct values with multiplicities.
1036fn compress_knots(knots: &[f64]) -> (Vec<usize>, Vec<f64>) {
1037    let mut mults = Vec::new();
1038    let mut values = Vec::new();
1039    for &k in knots {
1040        match values.last() {
1041            Some(&last) if k == last => {
1042                if let Some(m) = mults.last_mut() {
1043                    *m += 1;
1044                }
1045            }
1046            _ => {
1047                values.push(k);
1048                mults.push(1);
1049            }
1050        }
1051    }
1052    (mults, values)
1053}
1054
1055/// A rigid transform quantized to bits, for per-placement deduplication.
1056fn transform_bits(t: &Transform) -> [u64; 3] {
1057    let p = t.apply(Point::new(0.123_456_789, 9.87, -3.21));
1058    [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
1059}
1060
1061/// A location resolved to the rigid transform it composes to.
1062fn location_transform(location: &ogeom_topo::Location, model: &Model) -> OgeomResult<Transform> {
1063    let mut out = Transform::IDENTITY;
1064    for &(datum, power) in location.chain() {
1065        let Some(t) = model.datums().get(datum) else {
1066            ogeom_bail!(Dangling, "an instance placement names a missing datum");
1067        };
1068        let step = if power >= 0 { t } else { t.inverse()? };
1069        for _ in 0..power.unsigned_abs() {
1070            out = out * step;
1071        }
1072    }
1073    Ok(out)
1074}