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), and sheets (shells no solid owns, faces no shell owns)
6//! as trimmed surfaces (144), one per face, each bounded by curves on the
7//! surface (142) over its model-space edges, wireframe (wires and edges
8//! outside every face) as independent curves, and vertices outside every
9//! edge as independent points (116). Surfaces go out in their
10//! analytic spellings where IGES has one and as rational B-splines (128)
11//! where it does not. Curves defined in a
12//! plane of their own, arcs and ellipses, are written in definition space
13//! with a transformation matrix (124) carrying them to model space, which is
14//! how the format wants them.
15//!
16//! Everything is written in millimetres, model space, with geometry baked:
17//! the same decision the STEP writer made: a file carries positions, not this
18//! kernel's location chains.
19//!
20//! Construction geometry goes out after every part's bodies, each item one
21//! independent entity whose entity use flag says construction (06), and so
22//! does everything it refers to (IGES 5.3, section 2.2.4.4.9.3): a point
23//! (116); a curve as a body's edge curve is written, save that a hyperbola
24//! or a parabola is a conic arc (104) in its plane carried by a matrix
25//! (124); a surface as a trimmed surface (144) over the whole of a
26//! B-spline patch (128) of its window; an axis as a transformation matrix
27//! (124) alone, the frame's origin and axes its translation and columns,
28//! form 1 where the frame is left-handed. Each carries the item's name as
29//! a solid does, the label and a Name property (406, form 15).
30
31use ogeom_core::FastMap;
32use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
33use ogeom_geom::Curve3d as _;
34use ogeom_geom::Reversible as _;
35use ogeom_geom::Surface as _;
36use ogeom_geom::Transformable as _;
37use ogeom_geom::{BSplineCurve, Curve, SurfaceGeometry};
38use ogeom_math::{Frame, Point, Transform};
39use ogeom_topo::{EdgeRepr, Filter, NodeData, Shape, ShapeType, explore};
40
41/// Write a document's solids and sheets as an IGES file.
42///
43/// A solid becomes a manifold solid B-rep object (186). A sheet's faces
44/// become independent trimmed surfaces (144), which a reader sews back into
45/// the sheet they were; a sheet that closes reads back as a solid.
46/// A wire or edge outside every face is wireframe, written as its curve
47/// (an edge's own, a composite curve (102) of a wire's edges in the order
48/// the wire walks them) standing independent. A vertex outside every edge
49/// is a point (116) standing independent. The document's construction
50/// geometry goes out too; [`write_iges_reported`] says which items had no
51/// IGES spelling and were left out.
52///
53/// # Errors
54///
55/// As [`write_iges_reported`].
56pub fn write_iges(document: &ogeom_doc::Document, tol: Tolerances) -> OgeomResult<String> {
57    write_iges_reported(document, tol).map(|written| written.text)
58}
59
60/// A written IGES file, and what the writer left out of it.
61#[derive(Debug, Clone, Default)]
62pub struct IgesExport {
63    /// The deck.
64    pub text: String,
65    /// One line per construction item with no IGES spelling here, which
66    /// the deck leaves out, and per item whose name the deck carries only
67    /// in part.
68    pub warnings: Vec<String>,
69}
70
71/// Write a document as [`write_iges`] does, saying what it left out.
72///
73/// Each construction item goes out as an independent entity flagged as
74/// construction geometry, after every part's bodies, in the document's
75/// order: a point as a point (116); a line (110), circle or arc (100), an
76/// ellipse, hyperbola or parabola (104, the plane ones over a matrix, 124)
77/// and anything else as its exact B-spline (126); a surface as a trimmed
78/// surface (144) over the whole of the exact B-spline patch (128) of its
79/// window, a plane's and a B-spline's over their own parameters; an axis as
80/// the transformation matrix (124) taking the world frame to it. An item
81/// with no exact B-spline form (a helix, an offset of a free-form basis) is
82/// left out with a warning, and the rest of the deck is written.
83///
84/// # Errors
85///
86/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
87/// document holds no part and no construction geometry, a part holds
88/// nothing at all, a wire holds only degenerate edges, or a body carries
89/// geometry with no IGES spelling here; the error names the entity and the
90/// capability row.
91pub fn write_iges_reported(
92    document: &ogeom_doc::Document,
93    tol: Tolerances,
94) -> OgeomResult<IgesExport> {
95    let mut writer = Writer {
96        model: document.model(),
97        entities: Vec::new(),
98        vertices: FastMap::default(),
99        edges: FastMap::default(),
100        vertex_coords: Vec::new(),
101        edge_records: Vec::new(),
102        construction_from: usize::MAX,
103        tol,
104    };
105
106    let mut parts = Vec::new();
107    for (id, product) in document.products() {
108        let ogeom_doc::ProductKind::Part { shape } = &product.kind else {
109            continue;
110        };
111        // A part carrying construction geometry may hold no body.
112        let bodies = if document.construction_of(id).next().is_none() {
113            crate::bodies::bodies_of(writer.model, shape, "IGES", true)?
114        } else {
115            crate::bodies::bodies_within(writer.model, shape, "IGES", true)?
116        };
117        parts.push((product.name.clone(), bodies));
118    }
119    if parts.is_empty() && document.construction().is_empty() {
120        ogeom_bail!(Construction, "the document holds no part to write as IGES");
121    }
122    for (label, bodies) in parts {
123        for solid in &bodies.solids {
124            writer.solid(solid, &label)?;
125        }
126        for shell in &bodies.shells {
127            for face in writer.model.ordered_children_of(shell)? {
128                writer.trimmed_surface(&face, &label)?;
129            }
130        }
131        for face in &bodies.faces {
132            writer.trimmed_surface(face, &label)?;
133        }
134        for wire in &bodies.wires {
135            writer.wireframe(wire, &label)?;
136        }
137        for vertex in &bodies.vertices {
138            writer.free_point(vertex, &label)?;
139        }
140    }
141    let mut warnings = Vec::new();
142    writer.construction_from = writer.entities.len();
143    for item in document.construction() {
144        // An item that fails leaves nothing behind: entities only append,
145        // and nothing written before it points past where it began.
146        let mark = writer.entities.len();
147        match writer.construction(item) {
148            Ok(name_warning) => warnings.extend(name_warning),
149            Err(e) => {
150                writer.entities.truncate(mark);
151                warnings.push(format!(
152                    "construction item '{}' has no IGES spelling here and is left out: {e}",
153                    item.name
154                ));
155            }
156        }
157    }
158    Ok(IgesExport {
159        text: writer.serialize(),
160        warnings,
161    })
162}
163
164/// One pending entity: directory fields and parameter text.
165struct Pending {
166    kind: i64,
167    form: i64,
168    /// Index into `entities` of a 124 transform, or `None`.
169    transform: Option<usize>,
170    /// Independent (top-level) or physically subordinate.
171    independent: bool,
172    label: String,
173    params: String,
174}
175
176struct Writer<'a> {
177    model: &'a ogeom_topo::Model,
178    entities: Vec<Pending>,
179    /// Vertex index (1-based) in the current solid's 502, keyed by node
180    /// *and position*: an instanced node placed twice is two vertices in the
181    /// file, and keying by node alone would weld a prism's top to its bottom.
182    vertices: FastMap<(ogeom_topo::TShapeId, [u64; 3]), usize>,
183    /// Edge index (1-based) in the current solid's 504, keyed by node and
184    /// placement for the same reason.
185    edges: FastMap<(ogeom_topo::TShapeId, [u64; 3]), usize>,
186    /// The current solid's vertex coordinates, in 502 order.
187    vertex_coords: Vec<Point>,
188    /// The current solid's edge records: (curve entity, start index, end index).
189    edge_records: Vec<(usize, usize, usize)>,
190    /// The first entity written for construction geometry; it and every
191    /// entity after it carry the construction use flag.
192    construction_from: usize,
193    tol: Tolerances,
194}
195
196impl Writer<'_> {
197    fn push(&mut self, p: Pending) -> usize {
198        self.entities.push(p);
199        self.entities.len() - 1
200    }
201
202    /// The directory pointer an entity index will serialize as.
203    #[allow(clippy::cast_possible_wrap, reason = "entity counts are small")]
204    fn de(&self, index: usize) -> i64 {
205        2 * index as i64 + 1
206    }
207
208    fn solid(&mut self, solid: &Shape, label: &str) -> OgeomResult<()> {
209        // Fresh per-solid lists; their entities are created after the faces
210        // so their contents are complete, and patched into the loops by a
211        // placeholder scheme below.
212        self.vertices.clear();
213        self.edges.clear();
214        self.vertex_coords.clear();
215        self.edge_records.clear();
216
217        // The first shell is the outer boundary and each further one bounds
218        // a cavity. A void is written as the standard names it, a shell
219        // facing out of the cavity used the other way round, which leaves
220        // every face of the solid facing away from the material.
221        let shells = explore(self.model, solid, Filter::OfType(ShapeType::Shell))?;
222        if shells.is_empty() {
223            ogeom_bail!(Construction, "a solid with no shell cannot be written");
224        }
225        let mut shell_faces = Vec::with_capacity(shells.len());
226        for (k, shell) in shells.iter().enumerate() {
227            let mut face_entities = Vec::new();
228            for face in self.model.ordered_children_of(shell)? {
229                let face = if k == 0 { face } else { face.reversed() };
230                let (entity, turned) = self.face(&face)?;
231                let forward = face.orientation() != ogeom_topo::Orientation::Reversed;
232                face_entities.push((entity, forward != turned));
233            }
234            shell_faces.push(face_entities);
235        }
236
237        // The lists exist in full.
238        let vertex_list = {
239            let mut params = format!("{}", self.vertex_coords.len());
240            for p in &self.vertex_coords {
241                params.push_str(&format!(",{},{},{}", fmt(p.x), fmt(p.y), fmt(p.z)));
242            }
243            self.push(Pending {
244                kind: 502,
245                form: 1,
246                transform: None,
247                independent: false,
248                label: String::new(),
249                params,
250            })
251        };
252        let edge_list = {
253            let records = std::mem::take(&mut self.edge_records);
254            let mut params = format!("{}", records.len());
255            let mut curve_des = Vec::new();
256            for (curve_entity, sv, tv) in &records {
257                curve_des.push(self.de(*curve_entity));
258                params.push_str(&format!(",{},@V,{sv},@V,{tv}", self.de(*curve_entity)));
259            }
260            self.push(Pending {
261                kind: 504,
262                form: 1,
263                transform: None,
264                independent: false,
265                label: String::new(),
266                params,
267            })
268        };
269        // Loops referred to the lists before the lists existed; the
270        // placeholders resolve here.
271        let vlist_de = self.de(vertex_list);
272        let elist_de = self.de(edge_list);
273        for e in &mut self.entities {
274            if e.kind == 508 || e.kind == 504 {
275                e.params = e.params.replace("@E", &elist_de.to_string());
276                e.params = e.params.replace("@V", &vlist_de.to_string());
277            }
278        }
279
280        let mut shell_des = Vec::with_capacity(shell_faces.len());
281        for face_entities in &shell_faces {
282            let mut params = format!("{}", face_entities.len());
283            for (face, forward) in face_entities {
284                let flag = i32::from(*forward);
285                params.push_str(&format!(",{},{flag}", self.de(*face)));
286            }
287            let shell_entity = self.push(Pending {
288                kind: 514,
289                form: 1,
290                transform: None,
291                independent: false,
292                label: String::new(),
293                params,
294            });
295            shell_des.push(self.de(shell_entity));
296        }
297        let mut params = format!("{},1,{}", shell_des[0], shell_des.len() - 1);
298        for void in &shell_des[1..] {
299            params.push_str(&format!(",{void},0"));
300        }
301        // The part's whole name, past the label's eight columns, as the
302        // solid's Name property (406 form 15): no back pointers, one
303        // property.
304        if let Some(name) = self.name_property(label) {
305            params.push_str(&format!(",0,1,{}", self.de(name)));
306        }
307        self.push(Pending {
308            kind: 186,
309            form: 0,
310            transform: None,
311            independent: true,
312            label: label.chars().take(8).collect(),
313            params,
314        });
315        Ok(())
316    }
317
318    /// A Name property (406 form 15) holding `name`, or `None` for an empty
319    /// name or one a string parameter cannot carry whole here: past ASCII,
320    /// or holding a delimiter, which a record break could fall beside.
321    fn name_property(&mut self, name: &str) -> Option<usize> {
322        let plain = !name.is_empty()
323            && name
324                .chars()
325                .all(|c| (c.is_ascii_graphic() || c == ' ') && c != ',' && c != ';');
326        plain.then(|| {
327            self.push(Pending {
328                kind: 406,
329                form: 15,
330                transform: None,
331                independent: false,
332                label: String::new(),
333                params: format!("1,{}H{name}", name.len()),
334            })
335        })
336    }
337
338    /// A face (510) over its surface written in model space, its loops
339    /// running about the file's surface normal, and whether the face faces
340    /// against that normal (see [`crate::bodies::turned_in_file`]): the shell's flag for the
341    /// face is its orientation, turned once more where it does.
342    fn face(&mut self, face: &Shape) -> OgeomResult<(usize, bool)> {
343        let placement = face.transform(self.model.datums())?;
344        let surface = {
345            let Some(node) = self.model.node(face) else {
346                ogeom_bail!(Dangling, "face is not in this model");
347            };
348            let NodeData::Face(data) = node.data() else {
349                ogeom_bail!(Construction, "face node holds no face data");
350            };
351            let Some(surface) = self.model.geometry().surface(data.surface) else {
352                ogeom_bail!(Dangling, "face refers to a surface not in this model");
353            };
354            surface.clone().transformed(&placement, self.tol)?
355        };
356        let restated = self.restates(&surface)?;
357        let turned = crate::bodies::turned_in_file(&placement, restated);
358        let surface_entity = self.surface(&surface)?;
359        // A face's loops keep it on their left about its surface's normal,
360        // as the face stores them, and run the other way about a normal the
361        // file restates; the shell's flag says which side of the surface the
362        // face faces. The outer loop stays first.
363        let walked = if restated {
364            ogeom_topo::Orientation::Reversed
365        } else {
366            ogeom_topo::Orientation::Forward
367        };
368        let wires = self.model.children_of(&face.oriented(walked))?;
369        let mut loop_entities = Vec::new();
370        for wire in &wires {
371            if let Some(entity) = self.wire(wire)? {
372                loop_entities.push(entity);
373            }
374        }
375        if loop_entities.is_empty() {
376            ogeom_bail!(
377                Construction,
378                "a face whose every boundary is degenerate cannot be written \
379                 as IGES; see docs/CAPABILITIES.md, io.iges"
380            );
381        }
382        let mut params = format!("{},{},1", self.de(surface_entity), loop_entities.len());
383        for entity in &loop_entities {
384            params.push_str(&format!(",{}", self.de(*entity)));
385        }
386        let entity = self.push(Pending {
387            kind: 510,
388            form: 1,
389            transform: None,
390            independent: false,
391            label: String::new(),
392            params,
393        });
394        Ok((entity, turned))
395    }
396
397    /// A face as an independent trimmed surface (144): its surface, and one
398    /// curve on the surface (142) per boundary over the model-space curves
399    /// of the boundary's edges.
400    ///
401    /// The format gives a trimmed surface no sense of its own: its normal is
402    /// its surface's. A reversed face therefore goes out on its surface
403    /// turned over, a plane with its normal negated and anything else as its
404    /// exact B-spline patch with the two parameter directions exchanged.
405    fn trimmed_surface(&mut self, face: &Shape, label: &str) -> OgeomResult<()> {
406        let placement = face.transform(self.model.datums())?;
407        let surface = {
408            let Some(node) = self.model.node(face) else {
409                ogeom_bail!(Dangling, "face is not in this model");
410            };
411            let NodeData::Face(data) = node.data() else {
412                ogeom_bail!(Construction, "face node holds no face data");
413            };
414            let Some(surface) = self.model.geometry().surface(data.surface) else {
415                ogeom_bail!(Dangling, "face refers to a surface not in this model");
416            };
417            surface.clone().transformed(&placement, self.tol)?
418        };
419        let surface_entity = if face.orientation() == ogeom_topo::Orientation::Reversed {
420            self.turned_surface(&surface)?
421        } else {
422            self.surface(&surface)?
423        };
424        // Stored order, the outer boundary first; the walk of a reversed
425        // face lists its holes first.
426        let mut boundaries = Vec::new();
427        for wire in self.model.children_of(face)? {
428            if let Some(curve) = self.boundary_curve(&wire)? {
429                // Unspecified creation, no parameter-space curve, the
430                // model-space curve preferred.
431                let params = format!("0,{},0,{},2", self.de(surface_entity), self.de(curve));
432                boundaries.push(self.push(Pending {
433                    kind: 142,
434                    form: 0,
435                    transform: None,
436                    independent: false,
437                    label: String::new(),
438                    params,
439                }));
440            }
441        }
442        // A face with no wire at all is its whole surface: the trimmed
443        // surface whose outer boundary is the surface's own.
444        if self.model.children_of(face)?.is_empty() {
445            self.push(Pending {
446                kind: 144,
447                form: 0,
448                transform: None,
449                independent: true,
450                label: label.chars().take(8).collect(),
451                params: format!("{},0,0,0", self.de(surface_entity)),
452            });
453            return Ok(());
454        }
455        let Some((outer, inner)) = boundaries.split_first() else {
456            ogeom_bail!(
457                Construction,
458                "a face whose every boundary is degenerate cannot be written \
459                 as IGES; see docs/CAPABILITIES.md, io.iges"
460            );
461        };
462        let mut params = format!(
463            "{},1,{},{}",
464            self.de(surface_entity),
465            inner.len(),
466            self.de(*outer)
467        );
468        for boundary in inner {
469            params.push_str(&format!(",{}", self.de(*boundary)));
470        }
471        self.push(Pending {
472            kind: 144,
473            form: 0,
474            transform: None,
475            independent: true,
476            label: label.chars().take(8).collect(),
477            params,
478        });
479        Ok(())
480    }
481
482    /// A wire's edges as one model-space curve: the edge's own curve when
483    /// there is one, a composite curve (102) of them when there are more,
484    /// `None` when every edge is degenerate. Each piece runs the way the wire
485    /// walks it, in the wire's order: the composite curve has no sense flag
486    /// for its pieces, so an edge the wire uses reversed goes out as its
487    /// curve reversed.
488    fn boundary_curve(&mut self, wire: &Shape) -> OgeomResult<Option<usize>> {
489        let edges = self.model.ordered_children_of(wire)?;
490        self.chained_curve(&edges)
491    }
492
493    /// Wireframe, a free wire or edge, as its curve standing independent.
494    fn wireframe(&mut self, shape: &Shape, label: &str) -> OgeomResult<()> {
495        let edges = if self.model.kind_of(shape)? == ShapeType::Edge {
496            vec![shape.clone()]
497        } else {
498            self.model.ordered_children_of(shape)?
499        };
500        let Some(curve) = self.chained_curve(&edges)? else {
501            ogeom_bail!(
502                Construction,
503                "a free wire whose every edge is degenerate cannot be written as IGES"
504            );
505        };
506        let entity = &mut self.entities[curve];
507        entity.independent = true;
508        entity.label = label.chars().take(8).collect();
509        Ok(())
510    }
511
512    /// A free vertex as a point (116) standing independent, where its
513    /// placement puts it.
514    fn free_point(&mut self, vertex: &Shape, label: &str) -> OgeomResult<()> {
515        let Some(data) = self.model.node(vertex).and_then(|n| n.data().as_vertex()) else {
516            ogeom_bail!(Construction, "vertex node holds no vertex data");
517        };
518        let at = vertex.transform(self.model.datums())?.apply(data.point);
519        let point = self.point_entity(at);
520        let entity = &mut self.entities[point];
521        entity.independent = true;
522        entity.label = label.chars().take(8).collect();
523        Ok(())
524    }
525
526    /// A construction item as one independent entity, labelled and named
527    /// as a solid is. Returns a warning where the name goes out only in
528    /// part: a Name property carries a name of plain ASCII with no
529    /// delimiter here, and the label its first eight such characters.
530    fn construction(&mut self, item: &ogeom_doc::Construction) -> OgeomResult<Option<String>> {
531        use ogeom_doc::ConstructionGeometry as G;
532        let entity = match &item.geometry {
533            G::Point(p) => self.point_entity(*p),
534            G::Axis(frame) => {
535                let entity = self.transform_entity(frame);
536                if frame.handedness() == ogeom_math::Handedness::Left {
537                    self.entities[entity].form = 1;
538                }
539                entity
540            }
541            G::Curve { curve, range } => self.construction_curve(curve, *range)?,
542            G::Surface { surface, u, v } => self.construction_surface(surface, *u, *v)?,
543        };
544        // The B-spline curve's last defined parameters, its plane's unit
545        // normal, which zero leaves unstated; the property pointers follow.
546        if self.entities[entity].kind == 126 {
547            self.entities[entity].params.push_str(",0.,0.,0.");
548        }
549        let name = self.name_property(&item.name);
550        if let Some(name) = name {
551            let pointer = self.de(name);
552            self.entities[entity]
553                .params
554                .push_str(&format!(",0,1,{pointer}"));
555        }
556        let label: String = item
557            .name
558            .chars()
559            .filter(|c| c.is_ascii_graphic() || *c == ' ')
560            .take(8)
561            .collect();
562        let written = &mut self.entities[entity];
563        written.independent = true;
564        written.label.clone_from(&label);
565        Ok(name.is_none().then(|| {
566            format!(
567                "construction item '{}' keeps only its label '{}': a Name property \
568                 carries plain ASCII with no comma or semicolon",
569                item.name,
570                label.trim_end()
571            )
572        }))
573    }
574
575    /// A construction curve over its range: a hyperbola or a parabola as a
576    /// conic arc (104, forms 2 and 3), which an edge's curve does not
577    /// write; anything else as [`Self::curve`] writes an edge's.
578    fn construction_curve(&mut self, curve: &Curve, range: (f64, f64)) -> OgeomResult<usize> {
579        // The curve's own parameter at each end, a reversed one's mirrored
580        // in its domain, and the plane's frame turned over where the walk
581        // runs down the parameter, where each parameter negates.
582        let walk = |frame: Frame, domain: (f64, f64), reversed: bool| {
583            let (s, e) = if reversed {
584                (domain.0 + domain.1 - range.0, domain.0 + domain.1 - range.1)
585            } else {
586                range
587            };
588            if s <= e {
589                (frame, s, e)
590            } else {
591                (frame.with_z_reversed(), -s, -e)
592            }
593        };
594        let (form, frame, coefficients, ends) = match curve {
595            Curve::Hyperbola(h) => {
596                let hyperbola = h.hyperbola();
597                let (a, b) = (hyperbola.major_radius(), hyperbola.minor_radius());
598                let (frame, s, e) = walk(
599                    hyperbola.frame(),
600                    ogeom_geom::Curve3d::domain(h),
601                    h.is_reversed(),
602                );
603                let at = |t: f64| (a * t.cosh(), b * t.sinh());
604                // x²/a² − y²/b² − 1 = 0.
605                (
606                    2,
607                    frame,
608                    [1.0 / (a * a), 0.0, -1.0 / (b * b), 0.0, 0.0, -1.0],
609                    [at(s), at(e)],
610                )
611            }
612            Curve::Parabola(p) => {
613                let parabola = p.parabola();
614                let f = parabola.focal();
615                let (frame, s, e) = walk(
616                    parabola.frame(),
617                    ogeom_geom::Curve3d::domain(p),
618                    p.is_reversed(),
619                );
620                let at = |t: f64| (t * t / (4.0 * f), t);
621                // y² − 4 f x = 0.
622                (
623                    3,
624                    frame,
625                    [0.0, 0.0, 1.0, -4.0 * f, 0.0, 0.0],
626                    [at(s), at(e)],
627                )
628            }
629            _ => return self.curve(curve, range, true),
630        };
631        let transform = self.transform_entity(&frame);
632        let mut params = coefficients.map(fmt).join(",");
633        let [(sx, sy), (ex, ey)] = ends;
634        params.push_str(&format!(
635            ",0.,{},{},{},{}",
636            fmt(sx),
637            fmt(sy),
638            fmt(ex),
639            fmt(ey)
640        ));
641        Ok(self.push(Pending {
642            kind: 104,
643            form,
644            transform: Some(transform),
645            independent: false,
646            label: String::new(),
647            params,
648        }))
649    }
650
651    /// A construction surface as a trimmed surface (144) whose outer
652    /// boundary is its basis's own, over the exact B-spline patch (128) of
653    /// its window. A plane's patch is the bilinear one over the window's
654    /// own parameters and a B-spline's its segment, so either reads back
655    /// over the same window; any other surface's patch has parameters of
656    /// its own, its domain the window's image.
657    fn construction_surface(
658        &mut self,
659        surface: &SurfaceGeometry,
660        u: (f64, f64),
661        v: (f64, f64),
662    ) -> OgeomResult<usize> {
663        let patch = if let SurfaceGeometry::Plane(_) = surface {
664            let corner = |s: f64, t: f64| surface.point_at(s, t, self.tol);
665            let grid = ogeom_math::ControlGrid::new(
666                vec![
667                    corner(u.0, v.0)?,
668                    corner(u.0, v.1)?,
669                    corner(u.1, v.0)?,
670                    corner(u.1, v.1)?,
671                ],
672                2,
673                2,
674            )?;
675            ogeom_geom::BSplineSurface::new(
676                ogeom_math::KnotVector::new(vec![u.0, u.0, u.1, u.1], 1)?,
677                ogeom_math::KnotVector::new(vec![v.0, v.0, v.1, v.1], 1)?,
678                &grid,
679                self.tol,
680            )?
681        } else {
682            let window = ogeom_geom::TrimmedSurface::new(surface.clone(), u, v, self.tol)?;
683            SurfaceGeometry::Trimmed(Box::new(window)).to_bspline(self.tol)?
684        };
685        let basis = self.nurbs_surface(&patch, false);
686        let params = format!("{},0,0,0", self.de(basis));
687        Ok(self.push(Pending {
688            kind: 144,
689            form: 0,
690            transform: None,
691            independent: false,
692            label: String::new(),
693            params,
694        }))
695    }
696
697    /// Edges as one model-space curve, as [`Self::boundary_curve`] writes a
698    /// wire's.
699    fn chained_curve(&mut self, edges: &[Shape]) -> OgeomResult<Option<usize>> {
700        let mut pieces = Vec::new();
701        for edge in edges {
702            let forward = edge.orientation() != ogeom_topo::Orientation::Reversed;
703            let (curve, range) = {
704                let Some(data) = self.model.node(edge).and_then(|n| n.data().as_edge()) else {
705                    ogeom_bail!(Construction, "edge node holds no edge data");
706                };
707                if data.degenerate {
708                    continue;
709                }
710                let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
711                    ogeom_bail!(Construction, "an edge with no curve cannot be written");
712                };
713                let Some(geometry) = self.model.geometry().curve(*curve) else {
714                    ogeom_bail!(Dangling, "curve is not in this model");
715                };
716                let placement = edge.transform(self.model.datums())?;
717                (geometry.clone().transformed(&placement, self.tol)?, *range)
718            };
719            pieces.push(self.curve(&curve, range, forward)?);
720        }
721        match pieces.as_slice() {
722            [] => Ok(None),
723            [only] => Ok(Some(*only)),
724            _ => {
725                let mut params = format!("{}", pieces.len());
726                for piece in &pieces {
727                    params.push_str(&format!(",{}", self.de(*piece)));
728                }
729                Ok(Some(self.push(Pending {
730                    kind: 102,
731                    form: 0,
732                    transform: None,
733                    independent: false,
734                    label: String::new(),
735                    params,
736                })))
737            }
738        }
739    }
740
741    /// A surface with its normal turned over.
742    fn turned_surface(&mut self, surface: &SurfaceGeometry) -> OgeomResult<usize> {
743        match surface {
744            SurfaceGeometry::Plane(p) => {
745                let frame = p.plane().frame();
746                let point = self.point_entity(frame.origin());
747                let normal = self.direction_entity(-frame.z());
748                let params = format!("{},{}", self.de(point), self.de(normal));
749                Ok(self.push(Pending {
750                    kind: 190,
751                    form: 0,
752                    transform: None,
753                    independent: false,
754                    label: String::new(),
755                    params,
756                }))
757            }
758            SurfaceGeometry::BSpline(b) => Ok(self.nurbs_surface(b, true)),
759            other => {
760                if let Some(entity) = self.swept_surface(other, false)? {
761                    return Ok(entity);
762                }
763                let bspline = other.to_bspline(self.tol)?;
764                Ok(self.nurbs_surface(&bspline, true))
765            }
766        }
767    }
768
769    /// Whether this writer restates `surface`'s normal (see
770    /// [`crate::bodies::restates`]): an offset goes out as its analytic
771    /// form where it has one, or over its plane or spline basis, and either
772    /// says for it; any other offset goes out as a spline patch.
773    fn restates(&self, surface: &SurfaceGeometry) -> OgeomResult<bool> {
774        if let SurfaceGeometry::Offset(o) = surface {
775            if let Some(analytic) = o.analytic(self.tol)? {
776                return Ok(crate::bodies::restates(&analytic, false));
777            }
778            if matches!(
779                o.basis(),
780                SurfaceGeometry::Plane(_) | SurfaceGeometry::BSpline(_)
781            ) {
782                return Ok(crate::bodies::restates(o.basis(), false));
783            }
784        }
785        Ok(crate::bodies::restates(surface, false))
786    }
787
788    /// A surface of revolution (120), tabulated cylinder (122) or offset
789    /// surface (140) in its own spelling, its normal turned over where not
790    /// `forward`: a revolution's generatrix and a tabulated cylinder's
791    /// directrix written the other way round, an offset over its basis
792    /// turned over. `None` for an offset whose basis is not a plane or a
793    /// B-spline patch and has no analytic form, and for any other surface.
794    fn swept_surface(
795        &mut self,
796        surface: &SurfaceGeometry,
797        forward: bool,
798    ) -> OgeomResult<Option<usize>> {
799        use SurfaceGeometry as S;
800        let (kind, params) = match surface {
801            // The axis as a line, the generatrix over its whole range, and
802            // the turn as the start and terminate angles.
803            S::Revolution(r) => {
804                let ((a0, a1), (c0, c1)) = r.domain();
805                let axis = r.axis();
806                let (from, to) = (axis.location, axis.location + axis.direction.vector());
807                let line = self.push(Pending {
808                    kind: 110,
809                    form: 0,
810                    transform: None,
811                    independent: false,
812                    label: String::new(),
813                    params: format!(
814                        "{},{},{},{},{},{}",
815                        fmt(from.x),
816                        fmt(from.y),
817                        fmt(from.z),
818                        fmt(to.x),
819                        fmt(to.y),
820                        fmt(to.z)
821                    ),
822                });
823                let generatrix = self.curve(r.curve(), (c0, c1), forward)?;
824                (
825                    120,
826                    format!(
827                        "{},{},{},{}",
828                        self.de(line),
829                        self.de(generatrix),
830                        fmt(a0),
831                        fmt(a1)
832                    ),
833                )
834            }
835            // The directrix at the start of the sweep's window, and the far
836            // end of the generator through the directrix's first point.
837            S::Extrusion(e) => {
838                let ((u0, u1), (v0, v1)) = e.domain();
839                let along = e.direction().vector();
840                let directrix = e
841                    .curve()
842                    .transformed(&Transform::translation(along * v0), self.tol)?;
843                let start = directrix.point_at(if forward { u0 } else { u1 }, self.tol)?;
844                let far = start + along * (v1 - v0);
845                let curve = self.curve(&directrix, (u0, u1), forward)?;
846                (
847                    122,
848                    format!(
849                        "{},{},{},{}",
850                        self.de(curve),
851                        fmt(far.x),
852                        fmt(far.y),
853                        fmt(far.z)
854                    ),
855                )
856            }
857            S::Offset(o) => {
858                if let Some(analytic) = o.analytic(self.tol)? {
859                    let entity = if forward {
860                        self.surface(&analytic)?
861                    } else {
862                        self.turned_surface(&analytic)?
863                    };
864                    return Ok(Some(entity));
865                }
866                let basis = o.basis();
867                if !matches!(basis, S::Plane(_) | S::BSpline(_)) {
868                    return Ok(None);
869                }
870                // The reader takes the offset's side from the basis normal
871                // at the middle of its domain, which a plane and a written
872                // B-spline patch keep (a patch written turned over swaps its
873                // parameters, the middle staying where it was).
874                let ((ua, ub), (va, vb)) = basis.domain();
875                let normal = basis
876                    .normal_at(f64::midpoint(ua, ub), f64::midpoint(va, vb), self.tol)?
877                    .vector();
878                let (written, normal, distance) = if forward {
879                    (self.surface(basis)?, normal, o.distance())
880                } else {
881                    (self.turned_surface(basis)?, -normal, -o.distance())
882                };
883                let indicator = if distance < 0.0 { -normal } else { normal };
884                (
885                    140,
886                    format!(
887                        "{},{},{},{},{}",
888                        fmt(indicator.x),
889                        fmt(indicator.y),
890                        fmt(indicator.z),
891                        fmt(distance.abs()),
892                        self.de(written)
893                    ),
894                )
895            }
896            _ => return Ok(None),
897        };
898        Ok(Some(self.push(Pending {
899            kind,
900            form: 0,
901            transform: None,
902            independent: false,
903            label: String::new(),
904            params,
905        })))
906    }
907
908    /// A wire as a loop (508), or `None` when every edge in it is degenerate:
909    /// a pole has no curve, and the reader rebuilds chart degeneracies
910    /// from the surface itself.
911    fn wire(&mut self, wire: &Shape) -> OgeomResult<Option<usize>> {
912        let children = self.model.ordered_children_of(wire)?;
913        let degenerate = |edge: &Shape| {
914            self.model
915                .node(edge)
916                .and_then(|n| n.data().as_edge())
917                .is_some_and(|d| d.degenerate)
918        };
919        let live: Vec<&Shape> = children.iter().filter(|e| !degenerate(e)).collect();
920        if live.is_empty() {
921            return Ok(None);
922        }
923        let mut entries = Vec::new();
924        for edge in live {
925            let index = self.edge(edge)?;
926            let flag = i32::from(edge.orientation() != ogeom_topo::Orientation::Reversed);
927            entries.push(format!("0,@E,{index},{flag},0"));
928        }
929        let params = format!("{},{}", entries.len(), entries.join(","));
930        Ok(Some(self.push(Pending {
931            kind: 508,
932            form: 1,
933            transform: None,
934            independent: false,
935            label: String::new(),
936            params,
937        })))
938    }
939
940    /// The edge's index in the current solid's 504 list, creating it once
941    /// per placement.
942    fn edge(&mut self, edge: &Shape) -> OgeomResult<usize> {
943        let placement = edge.transform(self.model.datums())?;
944        let key = (edge.node(), transform_bits(&placement));
945        if let Some(&index) = self.edges.get(&key) {
946            return Ok(index);
947        }
948        let (curve, range) = {
949            let Some(data) = self.model.node(edge).and_then(|n| n.data().as_edge()) else {
950                ogeom_bail!(Construction, "edge node holds no edge data");
951            };
952            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
953                ogeom_bail!(Construction, "an edge with no curve cannot be written");
954            };
955            let Some(geometry) = self.model.geometry().curve(*curve) else {
956                ogeom_bail!(Dangling, "curve is not in this model");
957            };
958            (geometry.clone().transformed(&placement, self.tol)?, *range)
959        };
960        let curve_entity = self.curve(&curve, range, true)?;
961        let vertices = self.model.children_of(edge)?;
962        let (from, to) = match vertices.len() {
963            0 => ogeom_bail!(Construction, "an edge with no vertices cannot be written"),
964            1 => (vertices[0].clone(), vertices[0].clone()),
965            _ => (vertices[0].clone(), vertices[vertices.len() - 1].clone()),
966        };
967        // Each vertex carries its own composed placement; children_of has
968        // already folded the edge's chain in, and an instanced vertex (a
969        // prism's top corner is its bottom corner, moved) adds a hop of its
970        // own that the edge's placement alone would drop.
971        let sv = {
972            let at = from.transform(self.model.datums())?;
973            self.vertex(&from, &at)?
974        };
975        let tv = {
976            let at = to.transform(self.model.datums())?;
977            self.vertex(&to, &at)?
978        };
979        self.edge_records.push((curve_entity, sv, tv));
980        let index = self.edge_records.len();
981        self.edges.insert(key, index);
982        Ok(index)
983    }
984
985    /// The vertex's index in the current solid's 502 list, creating it once.
986    fn vertex(&mut self, vertex: &Shape, placement: &Transform) -> OgeomResult<usize> {
987        let Some(data) = self.model.node(vertex).and_then(|n| n.data().as_vertex()) else {
988            ogeom_bail!(Construction, "vertex node holds no vertex data");
989        };
990        let at = placement.apply(data.point);
991        let key = (
992            vertex.node(),
993            [at.x.to_bits(), at.y.to_bits(), at.z.to_bits()],
994        );
995        if let Some(&index) = self.vertices.get(&key) {
996            return Ok(index);
997        }
998        self.vertex_coords.push(at);
999        let index = self.vertex_coords.len();
1000        self.vertices.insert(key, index);
1001        Ok(index)
1002    }
1003
1004    /// A curve over a range, in its IGES spelling, running from `range.0`
1005    /// to `range.1` when `forward` and the other way when not.
1006    fn curve(&mut self, curve: &Curve, range: (f64, f64), forward: bool) -> OgeomResult<usize> {
1007        // The angle on the underlying conic at each end of the walk.
1008        let walk = |reversed: bool| {
1009            let angle = |u: f64| if reversed { -u } else { u };
1010            let (s, e) = if forward { range } else { (range.1, range.0) };
1011            // A conic arc runs counter-clockwise about its frame's `z`; one
1012            // walked clockwise goes out about the frame turned over `x`,
1013            // where each angle negates.
1014            let counter_clockwise = reversed != forward;
1015            let sign = if counter_clockwise { 1.0 } else { -1.0 };
1016            (sign * angle(s), sign * angle(e), counter_clockwise)
1017        };
1018        match curve {
1019            Curve::Line(line) => {
1020                let (from, to) = if forward { range } else { (range.1, range.0) };
1021                let a = line.point_at(from, self.tol)?;
1022                let b = line.point_at(to, self.tol)?;
1023                Ok(self.push(Pending {
1024                    kind: 110,
1025                    form: 0,
1026                    transform: None,
1027                    independent: false,
1028                    label: String::new(),
1029                    params: format!(
1030                        "{},{},{},{},{},{}",
1031                        fmt(a.x),
1032                        fmt(a.y),
1033                        fmt(a.z),
1034                        fmt(b.x),
1035                        fmt(b.y),
1036                        fmt(b.z)
1037                    ),
1038                }))
1039            }
1040            Curve::Circle(c) => {
1041                let circle = c.circle();
1042                let r = circle.radius();
1043                let (s, e, counter_clockwise) = walk(c.is_reversed());
1044                let frame = if counter_clockwise {
1045                    circle.frame()
1046                } else {
1047                    circle.frame().with_z_reversed()
1048                };
1049                let transform = self.transform_entity(&frame);
1050                let params = format!(
1051                    "0.,0.,0.,{},{},{},{}",
1052                    fmt(r * s.cos()),
1053                    fmt(r * s.sin()),
1054                    fmt(r * e.cos()),
1055                    fmt(r * e.sin()),
1056                );
1057                Ok(self.push(Pending {
1058                    kind: 100,
1059                    form: 0,
1060                    transform: Some(transform),
1061                    independent: false,
1062                    label: String::new(),
1063                    params,
1064                }))
1065            }
1066            Curve::Ellipse(el) => {
1067                let ellipse = el.ellipse();
1068                let (a, b) = (ellipse.major_radius(), ellipse.minor_radius());
1069                let (s, e, counter_clockwise) = walk(el.is_reversed());
1070                let frame = if counter_clockwise {
1071                    ellipse.frame()
1072                } else {
1073                    ellipse.frame().with_z_reversed()
1074                };
1075                let transform = self.transform_entity(&frame);
1076                let at = |t: f64| (a * t.cos(), b * t.sin());
1077                let (sx, sy) = at(s);
1078                let (ex, ey) = at(e);
1079                // x²/a² + y²/b² − 1 = 0, spelt in the general coefficients.
1080                let params = format!(
1081                    "{},0.,{},0.,0.,-1.,0.,{},{},{},{}",
1082                    fmt(1.0 / (a * a)),
1083                    fmt(1.0 / (b * b)),
1084                    fmt(sx),
1085                    fmt(sy),
1086                    fmt(ex),
1087                    fmt(ey),
1088                );
1089                Ok(self.push(Pending {
1090                    kind: 104,
1091                    form: 1,
1092                    transform: Some(transform),
1093                    independent: false,
1094                    label: String::new(),
1095                    params,
1096                }))
1097            }
1098            // A reversed trim runs its basis backwards, each parameter
1099            // mirrored in its own domain.
1100            Curve::Trimmed(t) if t.is_reversed() => {
1101                let (lo, hi) = ogeom_geom::Curve3d::domain(curve);
1102                self.curve(t.basis(), (lo + hi - range.1, lo + hi - range.0), !forward)
1103            }
1104            Curve::Trimmed(t) => self.curve(t.basis(), range, forward),
1105            Curve::BSpline(b) if forward => self.nurbs_curve(b, range),
1106            Curve::BSpline(b) => {
1107                // Reversal keeps the domain and mirrors each parameter in it.
1108                let (lo, hi) = ogeom_geom::Curve3d::domain(b);
1109                let back = reversed_bspline(b)?;
1110                self.nurbs_curve(&back, (lo + hi - range.1, lo + hi - range.0))
1111            }
1112            other => {
1113                // The exact conversion carries anything with a closed NURBS
1114                // form; what has none (a helix) is refused by name there.
1115                let mut bspline = other.to_bspline_over(range, self.tol)?;
1116                if !forward {
1117                    bspline = reversed_bspline(&bspline)?;
1118                }
1119                self.nurbs_curve(&bspline, ogeom_geom::Curve3d::domain(&bspline))
1120            }
1121        }
1122    }
1123
1124    fn nurbs_curve(&mut self, curve: &BSplineCurve, range: (f64, f64)) -> OgeomResult<usize> {
1125        let knots = curve.knots();
1126        let control = curve.control_points();
1127        let degree = knots.degree();
1128        let k = control.len() - 1;
1129        let rational = curve.is_rational();
1130        let closed = {
1131            let (lo, hi) = ogeom_geom::Curve3d::domain(curve);
1132            let a = curve.point_at(lo, self.tol)?;
1133            let b = curve.point_at(hi, self.tol)?;
1134            i32::from(a.distance(b) < self.tol.confusion())
1135        };
1136        let mut params = format!("{k},{degree},0,{closed},{},0", i32::from(!rational));
1137        for t in knots.knots() {
1138            params.push_str(&format!(",{}", fmt(*t)));
1139        }
1140        for w in control {
1141            params.push_str(&format!(",{}", fmt(w.weight)));
1142        }
1143        for w in control {
1144            let p = (*w).point();
1145            params.push_str(&format!(",{},{},{}", fmt(p.x), fmt(p.y), fmt(p.z)));
1146        }
1147        params.push_str(&format!(",{},{}", fmt(range.0), fmt(range.1)));
1148        Ok(self.push(Pending {
1149            kind: 126,
1150            form: 0,
1151            transform: None,
1152            independent: false,
1153            label: String::new(),
1154            params,
1155        }))
1156    }
1157
1158    /// A definition-space→model transform (124) for a frame.
1159    fn transform_entity(&mut self, frame: &Frame) -> usize {
1160        let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
1161        let o = frame.origin();
1162        let params = format!(
1163            "{},{},{},{},{},{},{},{},{},{},{},{}",
1164            fmt(x.x),
1165            fmt(y.x),
1166            fmt(z.x),
1167            fmt(o.x),
1168            fmt(x.y),
1169            fmt(y.y),
1170            fmt(z.y),
1171            fmt(o.y),
1172            fmt(x.z),
1173            fmt(y.z),
1174            fmt(z.z),
1175            fmt(o.z),
1176        );
1177        self.push(Pending {
1178            kind: 124,
1179            form: 0,
1180            transform: None,
1181            independent: false,
1182            label: String::new(),
1183            params,
1184        })
1185    }
1186
1187    /// A point entity (116).
1188    fn point_entity(&mut self, p: Point) -> usize {
1189        self.push(Pending {
1190            kind: 116,
1191            form: 0,
1192            transform: None,
1193            independent: false,
1194            label: String::new(),
1195            params: format!("{},{},{},0", fmt(p.x), fmt(p.y), fmt(p.z)),
1196        })
1197    }
1198
1199    /// A direction entity (123).
1200    fn direction_entity(&mut self, d: ogeom_math::Direction) -> usize {
1201        let v = d.vector();
1202        self.push(Pending {
1203            kind: 123,
1204            form: 0,
1205            transform: None,
1206            independent: false,
1207            label: String::new(),
1208            params: format!("{},{},{}", fmt(v.x), fmt(v.y), fmt(v.z)),
1209        })
1210    }
1211
1212    /// A surface in its IGES spelling.
1213    fn surface(&mut self, surface: &SurfaceGeometry) -> OgeomResult<usize> {
1214        use SurfaceGeometry as S;
1215        match surface {
1216            S::Plane(p) => {
1217                let frame = p.plane().frame();
1218                let point = self.point_entity(frame.origin());
1219                let normal = self.direction_entity(frame.z());
1220                let params = format!("{},{}", self.de(point), self.de(normal));
1221                Ok(self.push(Pending {
1222                    kind: 190,
1223                    form: 0,
1224                    transform: None,
1225                    independent: false,
1226                    label: String::new(),
1227                    params,
1228                }))
1229            }
1230            S::Cylinder(c) => {
1231                let cyl = c.cylinder();
1232                let frame = cyl.frame();
1233                let point = self.point_entity(frame.origin());
1234                let axis = self.direction_entity(frame.z());
1235                let params = format!("{},{},{}", self.de(point), self.de(axis), fmt(cyl.radius()));
1236                Ok(self.push(Pending {
1237                    kind: 192,
1238                    form: 0,
1239                    transform: None,
1240                    independent: false,
1241                    label: String::new(),
1242                    params,
1243                }))
1244            }
1245            S::Cone(c) => {
1246                let cone = c.cone();
1247                let frame = cone.frame();
1248                let point = self.point_entity(frame.origin());
1249                let axis = self.direction_entity(frame.z());
1250                let params = format!(
1251                    "{},{},{},{}",
1252                    self.de(point),
1253                    self.de(axis),
1254                    fmt(cone.reference_radius()),
1255                    fmt(cone.half_angle().to_degrees()),
1256                );
1257                Ok(self.push(Pending {
1258                    kind: 194,
1259                    form: 0,
1260                    transform: None,
1261                    independent: false,
1262                    label: String::new(),
1263                    params,
1264                }))
1265            }
1266            // Form 1, with the axis and the direction `u` starts from: a
1267            // sphere's poles are on its axis, and the face's poles and seam
1268            // with them.
1269            S::Sphere(s) => {
1270                let sphere = s.sphere();
1271                let frame = sphere.frame();
1272                let point = self.point_entity(sphere.centre());
1273                let axis = self.direction_entity(frame.z());
1274                let reference = self.direction_entity(frame.x());
1275                let params = format!(
1276                    "{},{},{},{}",
1277                    self.de(point),
1278                    fmt(sphere.radius()),
1279                    self.de(axis),
1280                    self.de(reference)
1281                );
1282                Ok(self.push(Pending {
1283                    kind: 196,
1284                    form: 1,
1285                    transform: None,
1286                    independent: false,
1287                    label: String::new(),
1288                    params,
1289                }))
1290            }
1291            S::Torus(t) => {
1292                let torus = t.torus();
1293                let frame = torus.frame();
1294                let point = self.point_entity(frame.origin());
1295                let axis = self.direction_entity(frame.z());
1296                let params = format!(
1297                    "{},{},{},{}",
1298                    self.de(point),
1299                    self.de(axis),
1300                    fmt(torus.major_radius()),
1301                    fmt(torus.minor_radius()),
1302                );
1303                Ok(self.push(Pending {
1304                    kind: 198,
1305                    form: 0,
1306                    transform: None,
1307                    independent: false,
1308                    label: String::new(),
1309                    params,
1310                }))
1311            }
1312            S::Revolution(_) | S::Extrusion(_) | S::Offset(_) => {
1313                match self.swept_surface(surface, true)? {
1314                    Some(entity) => Ok(entity),
1315                    None => {
1316                        let bspline = surface.to_bspline(self.tol)?;
1317                        self.surface(&SurfaceGeometry::BSpline(bspline))
1318                    }
1319                }
1320            }
1321            S::BSpline(b) => Ok(self.nurbs_surface(b, false)),
1322            other => {
1323                // Swept, offset and trimmed carriers convert exactly where a
1324                // closed NURBS form exists; the conversion refuses by name
1325                // where it does not.
1326                let bspline = other.to_bspline(self.tol)?;
1327                self.surface(&SurfaceGeometry::BSpline(bspline))
1328            }
1329        }
1330    }
1331
1332    /// A B-spline surface (128). `exchanged` writes it with its two
1333    /// parameter directions swapped: the same points, the normal turned over.
1334    fn nurbs_surface(&mut self, b: &ogeom_geom::BSplineSurface, exchanged: bool) -> usize {
1335        let (mut uk, mut vk) = (b.u_knots(), b.v_knots());
1336        let grid = b.grid();
1337        let (mut nu, mut nv) = (grid.u_count(), grid.v_count());
1338        let ((mut u0, mut u1), (mut v0, mut v1)) = b.domain();
1339        if exchanged {
1340            std::mem::swap(&mut uk, &mut vk);
1341            std::mem::swap(&mut nu, &mut nv);
1342            std::mem::swap(&mut u0, &mut v0);
1343            std::mem::swap(&mut u1, &mut v1);
1344        }
1345        // The control net at the file's (u, v), whichever way it is written.
1346        let at = |u: usize, v: usize| {
1347            if exchanged {
1348                grid.get(v, u)
1349            } else {
1350                grid.get(u, v)
1351            }
1352        };
1353        let (k1, k2) = (nu - 1, nv - 1);
1354        let (m1, m2) = (uk.degree(), vk.degree());
1355        let mut params = format!(
1356            "{k1},{k2},{m1},{m2},0,0,{},0,0",
1357            i32::from(!b.is_rational())
1358        );
1359        for t in uk.knots() {
1360            params.push_str(&format!(",{}", fmt(*t)));
1361        }
1362        for t in vk.knots() {
1363            params.push_str(&format!(",{}", fmt(*t)));
1364        }
1365        // The file wants the first (u) index varying fastest.
1366        for v in 0..nv {
1367            for u in 0..nu {
1368                let w = at(u, v).map_or(1.0, |w| w.weight);
1369                params.push_str(&format!(",{}", fmt(w)));
1370            }
1371        }
1372        for v in 0..nv {
1373            for u in 0..nu {
1374                let p = at(u, v).map_or(Point::ORIGIN, ogeom_math::Weighted::point);
1375                params.push_str(&format!(",{},{},{}", fmt(p.x), fmt(p.y), fmt(p.z)));
1376            }
1377        }
1378        params.push_str(&format!(",{},{},{},{}", fmt(u0), fmt(u1), fmt(v0), fmt(v1)));
1379        self.push(Pending {
1380            kind: 128,
1381            form: 0,
1382            transform: None,
1383            independent: false,
1384            label: String::new(),
1385            params,
1386        })
1387    }
1388
1389    /// The deck: start, global, directory, parameters, terminate.
1390    fn serialize(&self) -> String {
1391        let mut s = String::new();
1392        fn push_record(s: &mut String, body: &str, section: char, seq: usize) {
1393            s.push_str(&format!("{body:<72}{section}{seq:>7}\n"));
1394        }
1395        push_record(&mut s, "ogeom IGES writer", 'S', 1);
1396
1397        // Parameter text per entity, then directory and parameter sections
1398        // interleaved by the format's mutual pointers.
1399        let mut param_lines: Vec<Vec<String>> = Vec::with_capacity(self.entities.len());
1400        for e in &self.entities {
1401            let full = format!("{},{};", e.kind, e.params);
1402            param_lines.push(wrap_params(&full));
1403        }
1404        let mut param_starts = Vec::with_capacity(self.entities.len());
1405        let mut next_param = 1usize;
1406        for lines in &param_lines {
1407            param_starts.push(next_param);
1408            next_param += lines.len();
1409        }
1410
1411        let globals = [
1412            "1H,".to_string(),
1413            "1H;".to_string(),
1414            "5Hogeom".to_string(),
1415            "9Hmodel.igs".to_string(),
1416            "5Hogeom".to_string(),
1417            "5Hogeom".to_string(),
1418            "32".to_string(),
1419            "308".to_string(),
1420            "15".to_string(),
1421            "308".to_string(),
1422            "15".to_string(),
1423            "5Hogeom".to_string(),
1424            "1.".to_string(),
1425            // Millimetres, stated twice as the format wants.
1426            "2".to_string(),
1427            "2HMM".to_string(),
1428            "1".to_string(),
1429            "0.01".to_string(),
1430            "15H20260807.000000".to_string(),
1431            fmt(1e-7),
1432            "0.".to_string(),
1433            "5Hogeom".to_string(),
1434            "5Hogeom".to_string(),
1435            "11".to_string(),
1436            "0".to_string(),
1437            "15H20260807.000000".to_string(),
1438        ];
1439        let global_text = globals.join(",") + ";";
1440        let global_lines = wrap_params(&global_text);
1441        for (i, line) in global_lines.iter().enumerate() {
1442            push_record(&mut s, line, 'G', i + 1);
1443        }
1444
1445        let mut d_seq = 1usize;
1446        for (i, e) in self.entities.iter().enumerate() {
1447            let transform_de = e.transform.map_or(0, |t| self.de(t));
1448            let subordinate = if e.independent { "00" } else { "01" };
1449            let use_flag = if i >= self.construction_from {
1450                "06"
1451            } else {
1452                "00"
1453            };
1454            let status = format!("00{subordinate}{use_flag}00");
1455            let line1 = format!(
1456                "{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{status}",
1457                e.kind, param_starts[i], 0, 0, 0, 0, transform_de, 0
1458            );
1459            push_record(&mut s, &line1, 'D', d_seq);
1460            let line2 = format!(
1461                "{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}",
1462                e.kind,
1463                0,
1464                0,
1465                param_lines[i].len(),
1466                e.form,
1467                "",
1468                "",
1469                e.label,
1470                0
1471            );
1472            push_record(&mut s, &line2, 'D', d_seq + 1);
1473            d_seq += 2;
1474        }
1475
1476        let mut p_seq = 1usize;
1477        for (i, lines) in param_lines.iter().enumerate() {
1478            let back = self.de(i);
1479            for line in lines {
1480                s.push_str(&format!("{line:<64}{back:>8}P{p_seq:>7}\n"));
1481                p_seq += 1;
1482            }
1483        }
1484        let tail = format!(
1485            "S{:>7}G{:>7}D{:>7}P{:>7}",
1486            1,
1487            global_lines.len(),
1488            d_seq - 1,
1489            p_seq - 1
1490        );
1491        push_record(&mut s, &tail, 'T', 1);
1492        s
1493    }
1494}
1495
1496/// The same B-spline traversed the other way, over the same domain.
1497fn reversed_bspline(b: &BSplineCurve) -> OgeomResult<BSplineCurve> {
1498    match Curve::BSpline(b.clone()).reversed() {
1499        Curve::BSpline(back) => Ok(back),
1500        _ => ogeom_bail!(Construction, "a reversed B-spline is a B-spline"),
1501    }
1502}
1503
1504/// A rigid transform quantized to bits, for per-placement deduplication,
1505/// the same probe the STEP writer uses.
1506fn transform_bits(t: &Transform) -> [u64; 3] {
1507    let p = t.apply(Point::new(0.123_456_789, 9.87, -3.21));
1508    [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
1509}
1510
1511/// A real in the file, the shorter of its positional and exponent
1512/// spellings, each the shortest that reads back to the same value, with a
1513/// decimal point kept so a reader that types by spelling reads a real.
1514///
1515/// Positional notation alone spells a coefficient of 1.5e-51 in
1516/// sixty-nine characters, and a record holds sixty-four.
1517fn fmt(v: f64) -> String {
1518    let positional = {
1519        let s = format!("{v}");
1520        if s.contains('.') { s } else { format!("{s}.") }
1521    };
1522    let exponent = {
1523        let s = format!("{v:E}");
1524        match s.split_once('E') {
1525            Some((mantissa, power)) if !mantissa.contains('.') => {
1526                format!("{mantissa}.E{power}")
1527            }
1528            _ => s,
1529        }
1530    };
1531    if exponent.len() < positional.len() {
1532        exponent
1533    } else {
1534        positional
1535    }
1536}
1537
1538/// Parameter text into records of at most 64 data columns, split between
1539/// parameters.
1540///
1541/// The reader joins a parameter record's data columns as they stand,
1542/// padding included, so a parameter is never split where padding would
1543/// land inside it: one that does not fit starts a new record. One longer
1544/// than a whole record (only a long name can be) fills every record it
1545/// crosses to exactly 64 columns, so the pieces rejoin with nothing
1546/// between them.
1547fn wrap_params(text: &str) -> Vec<String> {
1548    const COLUMNS: usize = 64;
1549    let mut lines = Vec::new();
1550    let mut current = String::new();
1551    for piece in text.split_inclusive(',') {
1552        if current.len() + piece.len() > COLUMNS && piece.len() <= COLUMNS {
1553            lines.push(std::mem::take(&mut current));
1554        }
1555        let mut rest = piece;
1556        while current.len() + rest.len() > COLUMNS {
1557            let mut room = COLUMNS - current.len();
1558            while !rest.is_char_boundary(room) {
1559                room -= 1;
1560            }
1561            current.push_str(&rest[..room]);
1562            rest = &rest[room..];
1563            lines.push(std::mem::take(&mut current));
1564        }
1565        current.push_str(rest);
1566    }
1567    if !current.is_empty() {
1568        lines.push(current);
1569    }
1570    lines
1571}
1572
1573#[cfg(test)]
1574#[allow(clippy::unwrap_used, reason = "test code")]
1575mod tests {
1576    use super::{fmt, wrap_params};
1577
1578    #[test]
1579    fn a_real_takes_its_shorter_spelling_and_reads_back() {
1580        for v in [
1581            0.0,
1582            1.0,
1583            -2.5,
1584            1e-51,
1585            1.48776941870366e-51,
1586            2.76e-76,
1587            1e20,
1588            123_456.789,
1589        ] {
1590            let s = fmt(v);
1591            assert!(s.len() <= 24, "{v} spelt {s}");
1592            assert!(s.contains('.'), "{s} reads as a real");
1593            let back: f64 = s.replace('D', "E").parse().unwrap();
1594            assert_eq!(back, v, "{s}");
1595        }
1596        assert_eq!(fmt(1.0), "1.");
1597        assert_eq!(fmt(1e-51), "1.E-51");
1598    }
1599
1600    #[test]
1601    fn a_parameter_longer_than_a_record_fills_the_records_it_crosses() {
1602        let name = format!("100H{},", "x".repeat(100));
1603        let text = format!("128,{name}1.5,2.;");
1604        let lines = wrap_params(&text);
1605        assert!(lines.iter().all(|l| l.len() <= 64));
1606        // The pieces rejoin as written: every record the long name crosses
1607        // is full, so padding never lands inside it.
1608        let long: Vec<&String> = lines.iter().filter(|l| l.contains('x')).collect();
1609        for l in &long[..long.len() - 1] {
1610            assert_eq!(l.len(), 64);
1611        }
1612        assert_eq!(lines.concat(), text);
1613    }
1614}