Skip to main content

ogeom_io/iges/
write.rs

1//! From a document to an IGES deck.
2//!
3//! The writer emits manifold solid B-rep objects (entity 186 over shells
4//! (514), faces (510), loops (508), one edge list (504) and one vertex list
5//! (502) per solid), with surfaces in their analytic spellings where IGES has
6//! one and as rational B-splines (128) where it does not. Curves defined in a
7//! plane of their own, arcs and ellipses, are written in definition space
8//! with a transformation matrix (124) carrying them to model space, which is
9//! how the format wants them.
10//!
11//! Everything is written in millimetres, model space, with geometry baked:
12//! the same decision the STEP writer made: a file carries positions, not this
13//! kernel's location chains.
14
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_geom::Curve3d as _;
17use ogeom_geom::Surface as _;
18use ogeom_geom::Transformable as _;
19use ogeom_geom::{BSplineCurve, Curve, SurfaceGeometry};
20use ogeom_math::{Frame, Point, Transform};
21use ogeom_topo::{EdgeRepr, Filter, NodeData, Shape, ShapeType, explore};
22use std::collections::HashMap;
23
24/// Write a document's solids as an IGES file.
25///
26/// # Errors
27///
28/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
29/// document holds no solid, or a solid carries geometry with no IGES
30/// spelling here yet; the error names the entity and the parity row.
31pub fn write_iges(document: &ogeom_doc::Document, tol: Tolerances) -> OgeomResult<String> {
32    let mut writer = Writer {
33        model: document.model(),
34        entities: Vec::new(),
35        vertices: HashMap::new(),
36        edges: HashMap::new(),
37        vertex_coords: Vec::new(),
38        edge_records: Vec::new(),
39        tol,
40    };
41
42    let mut solids = Vec::new();
43    for (_, product) in document.products() {
44        let ogeom_doc::ProductKind::Part { shape } = &product.kind else {
45            continue;
46        };
47        for solid in explore(writer.model, shape, Filter::OfType(ShapeType::Solid))? {
48            solids.push((product.name.clone(), solid));
49        }
50    }
51    if solids.is_empty() {
52        ogeom_bail!(Construction, "the document holds no solid to write as IGES");
53    }
54    for (label, solid) in solids {
55        writer.solid(&solid, &label)?;
56    }
57    Ok(writer.serialize())
58}
59
60/// One pending entity: directory fields and parameter text.
61struct Pending {
62    kind: i64,
63    form: i64,
64    /// Index into `entities` of a 124 transform, or `None`.
65    transform: Option<usize>,
66    /// Independent (top-level) or physically subordinate.
67    independent: bool,
68    label: String,
69    params: String,
70}
71
72struct Writer<'a> {
73    model: &'a ogeom_topo::Model,
74    entities: Vec<Pending>,
75    /// Vertex index (1-based) in the current solid's 502, keyed by node
76    /// *and position*: an instanced node placed twice is two vertices in the
77    /// file, and keying by node alone would weld a prism's top to its bottom.
78    vertices: HashMap<(ogeom_topo::TShapeId, [u64; 3]), usize>,
79    /// Edge index (1-based) in the current solid's 504, keyed by node and
80    /// placement for the same reason.
81    edges: HashMap<(ogeom_topo::TShapeId, [u64; 3]), usize>,
82    /// The current solid's vertex coordinates, in 502 order.
83    vertex_coords: Vec<Point>,
84    /// The current solid's edge records: (curve entity, start index, end index).
85    edge_records: Vec<(usize, usize, usize)>,
86    tol: Tolerances,
87}
88
89impl Writer<'_> {
90    fn push(&mut self, p: Pending) -> usize {
91        self.entities.push(p);
92        self.entities.len() - 1
93    }
94
95    /// The directory pointer an entity index will serialize as.
96    #[allow(clippy::cast_possible_wrap, reason = "entity counts are small")]
97    fn de(&self, index: usize) -> i64 {
98        2 * index as i64 + 1
99    }
100
101    fn solid(&mut self, solid: &Shape, label: &str) -> OgeomResult<()> {
102        // Fresh per-solid lists; their entities are created after the faces
103        // so their contents are complete, and patched into the loops by a
104        // placeholder scheme below.
105        self.vertices.clear();
106        self.edges.clear();
107        self.vertex_coords.clear();
108        self.edge_records.clear();
109
110        let shells = explore(self.model, solid, Filter::OfType(ShapeType::Shell))?;
111        let Some(shell) = shells.first() else {
112            ogeom_bail!(Construction, "a solid with no shell cannot be written");
113        };
114        let mut face_entities = Vec::new();
115        for face in self.model.ordered_children_of(shell)? {
116            face_entities.push((self.face(&face)?, face.orientation()));
117        }
118
119        // Now the lists exist in full.
120        let vertex_list = {
121            let mut params = format!("{}", self.vertex_coords.len());
122            for p in &self.vertex_coords {
123                params.push_str(&format!(",{},{},{}", fmt(p.x), fmt(p.y), fmt(p.z)));
124            }
125            self.push(Pending {
126                kind: 502,
127                form: 1,
128                transform: None,
129                independent: false,
130                label: String::new(),
131                params,
132            })
133        };
134        let edge_list = {
135            let records = std::mem::take(&mut self.edge_records);
136            let mut params = format!("{}", records.len());
137            let mut curve_des = Vec::new();
138            for (curve_entity, sv, tv) in &records {
139                curve_des.push(self.de(*curve_entity));
140                params.push_str(&format!(",{},@V,{sv},@V,{tv}", self.de(*curve_entity)));
141            }
142            self.push(Pending {
143                kind: 504,
144                form: 1,
145                transform: None,
146                independent: false,
147                label: String::new(),
148                params,
149            })
150        };
151        // Loops referred to the lists before the lists existed; the
152        // placeholders resolve now.
153        let vlist_de = self.de(vertex_list);
154        let elist_de = self.de(edge_list);
155        for e in &mut self.entities {
156            if e.kind == 508 || e.kind == 504 {
157                e.params = e.params.replace("@E", &elist_de.to_string());
158                e.params = e.params.replace("@V", &vlist_de.to_string());
159            }
160        }
161
162        let shell_entity = {
163            let mut params = format!("{}", face_entities.len());
164            for (face, orientation) in &face_entities {
165                let flag = i32::from(*orientation != ogeom_topo::Orientation::Reversed);
166                params.push_str(&format!(",{},{flag}", self.de(*face)));
167            }
168            self.push(Pending {
169                kind: 514,
170                form: 1,
171                transform: None,
172                independent: false,
173                label: String::new(),
174                params,
175            })
176        };
177        let shell_de = self.de(shell_entity);
178        self.push(Pending {
179            kind: 186,
180            form: 0,
181            transform: None,
182            independent: true,
183            label: label.chars().take(8).collect(),
184            params: format!("{shell_de},1,0"),
185        });
186        Ok(())
187    }
188
189    fn face(&mut self, face: &Shape) -> OgeomResult<usize> {
190        let placement = face.transform(self.model.datums())?;
191        let surface = {
192            let Some(node) = self.model.node(face) else {
193                ogeom_bail!(Dangling, "face is not in this model");
194            };
195            let NodeData::Face(data) = node.data() else {
196                ogeom_bail!(Construction, "face node holds no face data");
197            };
198            let Some(surface) = self.model.geometry().surface(data.surface) else {
199                ogeom_bail!(Dangling, "face refers to a surface not in this model");
200            };
201            surface.clone().transformed(&placement, self.tol)?
202        };
203        let surface_entity = self.surface(&surface)?;
204        let wires = self.model.ordered_children_of(face)?;
205        let mut loop_entities = Vec::new();
206        for wire in &wires {
207            if let Some(entity) = self.wire(wire)? {
208                loop_entities.push(entity);
209            }
210        }
211        if loop_entities.is_empty() {
212            ogeom_bail!(
213                Construction,
214                "a face whose every boundary is degenerate cannot be written \
215                 as IGES; see docs/PARITY.md, io.iges"
216            );
217        }
218        let mut params = format!("{},{},1", self.de(surface_entity), loop_entities.len());
219        for entity in &loop_entities {
220            params.push_str(&format!(",{}", self.de(*entity)));
221        }
222        Ok(self.push(Pending {
223            kind: 510,
224            form: 1,
225            transform: None,
226            independent: false,
227            label: String::new(),
228            params,
229        }))
230    }
231
232    /// A wire as a loop (508), or `None` when every edge in it is degenerate:
233    /// a pole has no curve, and the reader rebuilds chart degeneracies
234    /// from the surface itself.
235    fn wire(&mut self, wire: &Shape) -> OgeomResult<Option<usize>> {
236        let children = self.model.ordered_children_of(wire)?;
237        let degenerate = |edge: &Shape| {
238            self.model
239                .node(edge)
240                .and_then(|n| n.data().as_edge())
241                .is_some_and(|d| d.degenerate)
242        };
243        let live: Vec<&Shape> = children.iter().filter(|e| !degenerate(e)).collect();
244        if live.is_empty() {
245            return Ok(None);
246        }
247        let mut entries = Vec::new();
248        for edge in live {
249            let index = self.edge(edge)?;
250            let flag = i32::from(edge.orientation() != ogeom_topo::Orientation::Reversed);
251            entries.push(format!("0,@E,{index},{flag},0"));
252        }
253        let params = format!("{},{}", entries.len(), entries.join(","));
254        Ok(Some(self.push(Pending {
255            kind: 508,
256            form: 1,
257            transform: None,
258            independent: false,
259            label: String::new(),
260            params,
261        })))
262    }
263
264    /// The edge's index in the current solid's 504 list, creating it once
265    /// per placement.
266    fn edge(&mut self, edge: &Shape) -> OgeomResult<usize> {
267        let placement = edge.transform(self.model.datums())?;
268        let key = (edge.node(), transform_bits(&placement));
269        if let Some(&index) = self.edges.get(&key) {
270            return Ok(index);
271        }
272        let (curve, range) = {
273            let Some(data) = self.model.node(edge).and_then(|n| n.data().as_edge()) else {
274                ogeom_bail!(Construction, "edge node holds no edge data");
275            };
276            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
277                ogeom_bail!(Construction, "an edge with no curve cannot be written");
278            };
279            let Some(geometry) = self.model.geometry().curve(*curve) else {
280                ogeom_bail!(Dangling, "curve is not in this model");
281            };
282            (geometry.clone().transformed(&placement, self.tol)?, *range)
283        };
284        let curve_entity = self.curve(&curve, range)?;
285        let vertices = self.model.children_of(edge)?;
286        let (from, to) = match vertices.len() {
287            0 => ogeom_bail!(Construction, "an edge with no vertices cannot be written"),
288            1 => (vertices[0].clone(), vertices[0].clone()),
289            _ => (vertices[0].clone(), vertices[vertices.len() - 1].clone()),
290        };
291        // Each vertex carries its own composed placement; children_of has
292        // already folded the edge's chain in, and an instanced vertex (a
293        // prism's top corner is its bottom corner, moved) adds a hop of its
294        // own that the edge's placement alone would drop.
295        let sv = {
296            let at = from.transform(self.model.datums())?;
297            self.vertex(&from, &at)?
298        };
299        let tv = {
300            let at = to.transform(self.model.datums())?;
301            self.vertex(&to, &at)?
302        };
303        self.edge_records.push((curve_entity, sv, tv));
304        let index = self.edge_records.len();
305        self.edges.insert(key, index);
306        Ok(index)
307    }
308
309    /// The vertex's index in the current solid's 502 list, creating it once.
310    fn vertex(&mut self, vertex: &Shape, placement: &Transform) -> OgeomResult<usize> {
311        let Some(data) = self.model.node(vertex).and_then(|n| n.data().as_vertex()) else {
312            ogeom_bail!(Construction, "vertex node holds no vertex data");
313        };
314        let at = placement.apply(data.point);
315        let key = (
316            vertex.node(),
317            [at.x.to_bits(), at.y.to_bits(), at.z.to_bits()],
318        );
319        if let Some(&index) = self.vertices.get(&key) {
320            return Ok(index);
321        }
322        self.vertex_coords.push(at);
323        let index = self.vertex_coords.len();
324        self.vertices.insert(key, index);
325        Ok(index)
326    }
327
328    /// A curve over a range, in its IGES spelling.
329    fn curve(&mut self, curve: &Curve, range: (f64, f64)) -> OgeomResult<usize> {
330        match curve {
331            Curve::Line(line) => {
332                let a = line.point_at(range.0, self.tol)?;
333                let b = line.point_at(range.1, self.tol)?;
334                Ok(self.push(Pending {
335                    kind: 110,
336                    form: 0,
337                    transform: None,
338                    independent: false,
339                    label: String::new(),
340                    params: format!(
341                        "{},{},{},{},{},{}",
342                        fmt(a.x),
343                        fmt(a.y),
344                        fmt(a.z),
345                        fmt(b.x),
346                        fmt(b.y),
347                        fmt(b.z)
348                    ),
349                }))
350            }
351            Curve::Circle(c) => {
352                let circle = c.circle();
353                let r = circle.radius();
354                let transform = self.transform_entity(&circle.frame());
355                let (s, e) = (range.0, range.1);
356                let params = format!(
357                    "0.,0.,0.,{},{},{},{}",
358                    fmt(r * s.cos()),
359                    fmt(r * s.sin()),
360                    fmt(r * e.cos()),
361                    fmt(r * e.sin()),
362                );
363                Ok(self.push(Pending {
364                    kind: 100,
365                    form: 0,
366                    transform: Some(transform),
367                    independent: false,
368                    label: String::new(),
369                    params,
370                }))
371            }
372            Curve::Ellipse(el) => {
373                let ellipse = el.ellipse();
374                let (a, b) = (ellipse.major_radius(), ellipse.minor_radius());
375                let transform = self.transform_entity(&ellipse.frame());
376                let at = |t: f64| (a * t.cos(), b * t.sin());
377                let (sx, sy) = at(range.0);
378                let (ex, ey) = at(range.1);
379                // x²/a² + y²/b² − 1 = 0, spelt in the general coefficients.
380                let params = format!(
381                    "{},0.,{},0.,0.,-1.,0.,{},{},{},{}",
382                    fmt(1.0 / (a * a)),
383                    fmt(1.0 / (b * b)),
384                    fmt(sx),
385                    fmt(sy),
386                    fmt(ex),
387                    fmt(ey),
388                );
389                Ok(self.push(Pending {
390                    kind: 104,
391                    form: 1,
392                    transform: Some(transform),
393                    independent: false,
394                    label: String::new(),
395                    params,
396                }))
397            }
398            Curve::Trimmed(t) => self.curve(t.basis(), range),
399            Curve::BSpline(b) => self.nurbs_curve(b, range),
400            other => {
401                // The exact conversion carries anything with a closed NURBS
402                // form; what has none (a helix) is refused by name there.
403                let bspline = other.to_bspline_over(range, self.tol)?;
404                self.nurbs_curve(&bspline, ogeom_geom::Curve3d::domain(&bspline))
405            }
406        }
407    }
408
409    fn nurbs_curve(&mut self, curve: &BSplineCurve, range: (f64, f64)) -> OgeomResult<usize> {
410        let knots = curve.knots();
411        let control = curve.control_points();
412        let degree = knots.degree();
413        let k = control.len() - 1;
414        let rational = curve.is_rational();
415        let closed = {
416            let (lo, hi) = ogeom_geom::Curve3d::domain(curve);
417            let a = curve.point_at(lo, self.tol)?;
418            let b = curve.point_at(hi, self.tol)?;
419            i32::from(a.distance(b) < self.tol.confusion())
420        };
421        let mut params = format!("{k},{degree},0,{closed},{},0", i32::from(!rational));
422        for t in knots.knots() {
423            params.push_str(&format!(",{}", fmt(*t)));
424        }
425        for w in control {
426            params.push_str(&format!(",{}", fmt(w.weight)));
427        }
428        for w in control {
429            let p = (*w).point();
430            params.push_str(&format!(",{},{},{}", fmt(p.x), fmt(p.y), fmt(p.z)));
431        }
432        params.push_str(&format!(",{},{}", fmt(range.0), fmt(range.1)));
433        Ok(self.push(Pending {
434            kind: 126,
435            form: 0,
436            transform: None,
437            independent: false,
438            label: String::new(),
439            params,
440        }))
441    }
442
443    /// A definition-space→model transform (124) for a frame.
444    fn transform_entity(&mut self, frame: &Frame) -> usize {
445        let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
446        let o = frame.origin();
447        let params = format!(
448            "{},{},{},{},{},{},{},{},{},{},{},{}",
449            fmt(x.x),
450            fmt(y.x),
451            fmt(z.x),
452            fmt(o.x),
453            fmt(x.y),
454            fmt(y.y),
455            fmt(z.y),
456            fmt(o.y),
457            fmt(x.z),
458            fmt(y.z),
459            fmt(z.z),
460            fmt(o.z),
461        );
462        self.push(Pending {
463            kind: 124,
464            form: 0,
465            transform: None,
466            independent: false,
467            label: String::new(),
468            params,
469        })
470    }
471
472    /// A point entity (116).
473    fn point_entity(&mut self, p: Point) -> usize {
474        self.push(Pending {
475            kind: 116,
476            form: 0,
477            transform: None,
478            independent: false,
479            label: String::new(),
480            params: format!("{},{},{},0", fmt(p.x), fmt(p.y), fmt(p.z)),
481        })
482    }
483
484    /// A direction entity (123).
485    fn direction_entity(&mut self, d: ogeom_math::Direction) -> usize {
486        let v = d.vector();
487        self.push(Pending {
488            kind: 123,
489            form: 0,
490            transform: None,
491            independent: false,
492            label: String::new(),
493            params: format!("{},{},{}", fmt(v.x), fmt(v.y), fmt(v.z)),
494        })
495    }
496
497    /// A surface in its IGES spelling.
498    fn surface(&mut self, surface: &SurfaceGeometry) -> OgeomResult<usize> {
499        use SurfaceGeometry as S;
500        match surface {
501            S::Plane(p) => {
502                let frame = p.plane().frame();
503                let point = self.point_entity(frame.origin());
504                let normal = self.direction_entity(frame.z());
505                let params = format!("{},{}", self.de(point), self.de(normal));
506                Ok(self.push(Pending {
507                    kind: 190,
508                    form: 0,
509                    transform: None,
510                    independent: false,
511                    label: String::new(),
512                    params,
513                }))
514            }
515            S::Cylinder(c) => {
516                let cyl = c.cylinder();
517                let frame = cyl.frame();
518                let point = self.point_entity(frame.origin());
519                let axis = self.direction_entity(frame.z());
520                let params = format!("{},{},{}", self.de(point), self.de(axis), fmt(cyl.radius()));
521                Ok(self.push(Pending {
522                    kind: 192,
523                    form: 0,
524                    transform: None,
525                    independent: false,
526                    label: String::new(),
527                    params,
528                }))
529            }
530            S::Cone(c) => {
531                let cone = c.cone();
532                let frame = cone.frame();
533                let point = self.point_entity(frame.origin());
534                let axis = self.direction_entity(frame.z());
535                let params = format!(
536                    "{},{},{},{}",
537                    self.de(point),
538                    self.de(axis),
539                    fmt(cone.reference_radius()),
540                    fmt(cone.half_angle().to_degrees()),
541                );
542                Ok(self.push(Pending {
543                    kind: 194,
544                    form: 0,
545                    transform: None,
546                    independent: false,
547                    label: String::new(),
548                    params,
549                }))
550            }
551            S::Sphere(s) => {
552                let sphere = s.sphere();
553                let point = self.point_entity(sphere.centre());
554                let params = format!("{},{}", self.de(point), fmt(sphere.radius()));
555                Ok(self.push(Pending {
556                    kind: 196,
557                    form: 0,
558                    transform: None,
559                    independent: false,
560                    label: String::new(),
561                    params,
562                }))
563            }
564            S::Torus(t) => {
565                let torus = t.torus();
566                let frame = torus.frame();
567                let point = self.point_entity(frame.origin());
568                let axis = self.direction_entity(frame.z());
569                let params = format!(
570                    "{},{},{},{}",
571                    self.de(point),
572                    self.de(axis),
573                    fmt(torus.major_radius()),
574                    fmt(torus.minor_radius()),
575                );
576                Ok(self.push(Pending {
577                    kind: 198,
578                    form: 0,
579                    transform: None,
580                    independent: false,
581                    label: String::new(),
582                    params,
583                }))
584            }
585            S::BSpline(b) => {
586                let (uk, vk) = (b.u_knots(), b.v_knots());
587                let grid = b.grid();
588                let (nu, nv) = (grid.u_count(), grid.v_count());
589                let (k1, k2) = (nu - 1, nv - 1);
590                let (m1, m2) = (uk.degree(), vk.degree());
591                let mut params = format!(
592                    "{k1},{k2},{m1},{m2},0,0,{},0,0",
593                    i32::from(!b.is_rational())
594                );
595                for t in uk.knots() {
596                    params.push_str(&format!(",{}", fmt(*t)));
597                }
598                for t in vk.knots() {
599                    params.push_str(&format!(",{}", fmt(*t)));
600                }
601                // The file wants the first (u) index varying fastest.
602                for v in 0..nv {
603                    for u in 0..nu {
604                        let w = grid.get(u, v).map_or(1.0, |w| w.weight);
605                        params.push_str(&format!(",{}", fmt(w)));
606                    }
607                }
608                for v in 0..nv {
609                    for u in 0..nu {
610                        let p = grid
611                            .get(u, v)
612                            .map_or(Point::ORIGIN, ogeom_math::Weighted::point);
613                        params.push_str(&format!(",{},{},{}", fmt(p.x), fmt(p.y), fmt(p.z)));
614                    }
615                }
616                let ((u0, u1), (v0, v1)) = b.domain();
617                params.push_str(&format!(",{},{},{},{}", fmt(u0), fmt(u1), fmt(v0), fmt(v1)));
618                Ok(self.push(Pending {
619                    kind: 128,
620                    form: 0,
621                    transform: None,
622                    independent: false,
623                    label: String::new(),
624                    params,
625                }))
626            }
627            other => {
628                // Swept, offset and trimmed carriers convert exactly where a
629                // closed NURBS form exists; the conversion refuses by name
630                // where it does not.
631                let bspline = other.to_bspline(self.tol)?;
632                self.surface(&SurfaceGeometry::BSpline(bspline))
633            }
634        }
635    }
636
637    /// The deck: start, global, directory, parameters, terminate.
638    fn serialize(&self) -> String {
639        let mut s = String::new();
640        fn push_record(s: &mut String, body: &str, section: char, seq: usize) {
641            s.push_str(&format!("{body:<72}{section}{seq:>7}\n"));
642        }
643        push_record(&mut s, "ogeom IGES writer", 'S', 1);
644
645        // Parameter text per entity, then directory and parameter sections
646        // interleaved by the format's mutual pointers.
647        let mut param_lines: Vec<Vec<String>> = Vec::with_capacity(self.entities.len());
648        for e in &self.entities {
649            let full = format!("{},{};", e.kind, e.params);
650            param_lines.push(wrap_params(&full));
651        }
652        let mut param_starts = Vec::with_capacity(self.entities.len());
653        let mut next_param = 1usize;
654        for lines in &param_lines {
655            param_starts.push(next_param);
656            next_param += lines.len();
657        }
658
659        let globals = [
660            "1H,".to_string(),
661            "1H;".to_string(),
662            "5Hogeom".to_string(),
663            "9Hmodel.igs".to_string(),
664            "5Hogeom".to_string(),
665            "5Hogeom".to_string(),
666            "32".to_string(),
667            "308".to_string(),
668            "15".to_string(),
669            "308".to_string(),
670            "15".to_string(),
671            "5Hogeom".to_string(),
672            "1.".to_string(),
673            // Millimetres, stated twice as the format wants.
674            "2".to_string(),
675            "2HMM".to_string(),
676            "1".to_string(),
677            "0.01".to_string(),
678            "15H20260807.000000".to_string(),
679            fmt(1e-7),
680            "0.".to_string(),
681            "5Hogeom".to_string(),
682            "5Hogeom".to_string(),
683            "11".to_string(),
684            "0".to_string(),
685            "15H20260807.000000".to_string(),
686        ];
687        let global_text = globals.join(",") + ";";
688        let global_lines = wrap_params(&global_text);
689        for (i, line) in global_lines.iter().enumerate() {
690            push_record(&mut s, line, 'G', i + 1);
691        }
692
693        let mut d_seq = 1usize;
694        for (i, e) in self.entities.iter().enumerate() {
695            let transform_de = e.transform.map_or(0, |t| self.de(t));
696            let status = if e.independent {
697                "00000000"
698            } else {
699                "00010000"
700            };
701            let line1 = format!(
702                "{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{status}",
703                e.kind, param_starts[i], 0, 0, 0, 0, transform_de, 0
704            );
705            push_record(&mut s, &line1, 'D', d_seq);
706            let line2 = format!(
707                "{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}",
708                e.kind,
709                0,
710                0,
711                param_lines[i].len(),
712                e.form,
713                "",
714                "",
715                e.label,
716                0
717            );
718            push_record(&mut s, &line2, 'D', d_seq + 1);
719            d_seq += 2;
720        }
721
722        let mut p_seq = 1usize;
723        for (i, lines) in param_lines.iter().enumerate() {
724            let back = self.de(i);
725            for line in lines {
726                s.push_str(&format!("{line:<64}{back:>8}P{p_seq:>7}\n"));
727                p_seq += 1;
728            }
729        }
730        let tail = format!(
731            "S{:>7}G{:>7}D{:>7}P{:>7}",
732            1,
733            global_lines.len(),
734            d_seq - 1,
735            p_seq - 1
736        );
737        push_record(&mut s, &tail, 'T', 1);
738        s
739    }
740}
741
742/// A rigid transform quantized to bits, for per-placement deduplication,
743/// the same probe the STEP writer uses.
744fn transform_bits(t: &Transform) -> [u64; 3] {
745    let p = t.apply(Point::new(0.123_456_789, 9.87, -3.21));
746    [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
747}
748
749/// A real in the file, the shorter of its positional and exponent
750/// spellings, each the shortest that reads back to the same value, with a
751/// decimal point kept so a reader that types by spelling reads a real.
752///
753/// Positional notation alone spells a coefficient of 1.5e-51 in
754/// sixty-nine characters, and a record holds sixty-four.
755fn fmt(v: f64) -> String {
756    let positional = {
757        let s = format!("{v}");
758        if s.contains('.') { s } else { format!("{s}.") }
759    };
760    let exponent = {
761        let s = format!("{v:E}");
762        match s.split_once('E') {
763            Some((mantissa, power)) if !mantissa.contains('.') => {
764                format!("{mantissa}.E{power}")
765            }
766            _ => s,
767        }
768    };
769    if exponent.len() < positional.len() {
770        exponent
771    } else {
772        positional
773    }
774}
775
776/// Parameter text into records of at most 64 data columns, split between
777/// parameters.
778///
779/// The reader joins a parameter record's data columns as they stand,
780/// padding included, so a parameter is never split where padding would
781/// land inside it: one that does not fit starts a new record. One longer
782/// than a whole record (only a long name can be) fills every record it
783/// crosses to exactly 64 columns, so the pieces rejoin with nothing
784/// between them.
785fn wrap_params(text: &str) -> Vec<String> {
786    const COLUMNS: usize = 64;
787    let mut lines = Vec::new();
788    let mut current = String::new();
789    for piece in text.split_inclusive(',') {
790        if current.len() + piece.len() > COLUMNS && piece.len() <= COLUMNS {
791            lines.push(std::mem::take(&mut current));
792        }
793        let mut rest = piece;
794        while current.len() + rest.len() > COLUMNS {
795            let mut room = COLUMNS - current.len();
796            while !rest.is_char_boundary(room) {
797                room -= 1;
798            }
799            current.push_str(&rest[..room]);
800            rest = &rest[room..];
801            lines.push(std::mem::take(&mut current));
802        }
803        current.push_str(rest);
804    }
805    if !current.is_empty() {
806        lines.push(current);
807    }
808    lines
809}
810
811#[cfg(test)]
812#[allow(clippy::unwrap_used, reason = "test code")]
813mod tests {
814    use super::{fmt, wrap_params};
815
816    #[test]
817    fn a_real_takes_its_shorter_spelling_and_reads_back() {
818        for v in [
819            0.0,
820            1.0,
821            -2.5,
822            1e-51,
823            1.48776941870366e-51,
824            2.76e-76,
825            1e20,
826            123_456.789,
827        ] {
828            let s = fmt(v);
829            assert!(s.len() <= 24, "{v} spelt {s}");
830            assert!(s.contains('.'), "{s} reads as a real");
831            let back: f64 = s.replace('D', "E").parse().unwrap();
832            assert_eq!(back, v, "{s}");
833        }
834        assert_eq!(fmt(1.0), "1.");
835        assert_eq!(fmt(1e-51), "1.E-51");
836    }
837
838    #[test]
839    fn a_parameter_longer_than_a_record_fills_the_records_it_crosses() {
840        let name = format!("100H{},", "x".repeat(100));
841        let text = format!("128,{name}1.5,2.;");
842        let lines = wrap_params(&text);
843        assert!(lines.iter().all(|l| l.len() <= 64));
844        // The pieces rejoin as written: every record the long name crosses
845        // is full, so padding never lands inside it.
846        let long: Vec<&String> = lines.iter().filter(|l| l.contains('x')).collect();
847        for l in &long[..long.len() - 1] {
848            assert_eq!(l.len(), 64);
849        }
850        assert_eq!(lines.concat(), text);
851    }
852}