Skip to main content

ogeom_io/
native.rs

1//! The native `.og` format: a whole document, written so it can be read back.
2//!
3//! Topology, geometry, placements, per-entity tolerances, provenance and the
4//! cached tessellation: everything a [`Model`] holds. This is what the
5//! differential harness round-trips through, which sets the bar: a format that
6//! quietly dropped what it did not handle would make every comparison through
7//! it pass, including the ones that should not.
8//!
9//! # Text, not binary
10//!
11//! The point of a round trip is to *disagree usefully* when it goes wrong, and
12//! a disagreement you can only see through a hex dump is one nobody will read.
13//! So: one record per line, tagged, in dependency order: datums, geometry,
14//! entities, then topology. A model written, read and written again produces
15//! the same bytes, so `diff` is the whole comparison tool.
16//!
17//! Floats are printed in Rust's shortest round-tripping form, which is exact:
18//! parsing what this writes gives the same `f64` back, bit for bit. It is not a
19//! lossy text approximation of a binary truth.
20//!
21//! # Handles are preserved, not renumbered
22//!
23//! A document's entity identities are the thing references are recorded
24//! against (`docs/DATA_MODEL.md` §8). Rebuilding a model through the builders
25//! would mint fresh ones and quietly invalidate every reference in every
26//! document that pointed into it, so reading goes through
27//! [`Model::from_parts`], which reproduces the arenas exactly and checks that
28//! every handle resolves.
29//!
30//! # What it refuses
31//!
32//! A representation, curve, surface or node kind this writer does not know how
33//! to write is an error, not a silent omission. That is the whole discipline of
34//! the format: `write` either produces a file that reads back as the same
35//! model, or it says it cannot.
36
37use std::fmt::Write as _;
38
39use ogeom_core::{
40    EntityId, Key, OgeomResult, OpId, Provenance, Role, SourceId, Tolerance, Tolerances, ogeom_bail,
41};
42use ogeom_geom::{
43    BSpline2d, BSplineCurve, BSplineSurface, Circle2d, CircleCurve, ConeSurface, Curve, Curve2d,
44    Curve3d, CylinderSurface, Ellipse2d, EllipseCurve, ExtrusionSurface, HelixCurve,
45    HyperbolaCurve, Line2d, LineCurve, ParabolaCurve, PlanarCurve, PlaneSurface, RevolutionSurface,
46    SphereSurface, Surface, SurfaceGeometry, TorusSurface, Trimmed2d, TrimmedCurve, TrimmedSurface,
47};
48use ogeom_math::{
49    Axis, Axis2, Circle, Circle2, Cone, ControlGrid, Cylinder, Direction, Direction2, Ellipse,
50    Ellipse2, Frame, Frame2, Hyperbola, Hyperbola2, KnotVector, Parabola, Plane, Point, Point2,
51    Sphere, Torus, Transform, Vector, Weighted,
52};
53use ogeom_topo::{
54    DatumId, EdgeData, EdgeRepr, FaceData, GeometryStore, Location, Model, ModelParts, NodeData,
55    Orientation, Shape, ShapeType, TShape, TShapeId, Triangulation, VertexData,
56};
57
58pub use ogeom_topo::Absorbed;
59
60/// The newest format version this writes.
61///
62/// Bumped when the grammar changes in a way an older reader could not follow.
63/// A file naming a version this does not know is refused rather than guessed
64/// at; every version up to this one still reads, because each version only
65/// *added* to the one before: version 2 the conical helix and the periodic
66/// B-spline, version 3 entity tables with gaps in their ids. A file is
67/// written at the oldest version that holds it.
68pub const VERSION: u32 = 3;
69
70/// The version a file whose entity ids run without gaps is written at.
71const GAPLESS_VERSION: u32 = 2;
72
73/// The word every file starts with.
74const MAGIC: &str = "ogeom";
75
76/// What to include when writing.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct WriteOptions {
79    /// Whether to write the cached triangulations.
80    ///
81    /// They are derived data (recomputable from the topology at any deflection)
82    /// and far larger than the topology that produced them, so a file meant
83    /// for reading by a human is better without. Left on by default, because
84    /// the alternative is a round trip that is not the identity for a model
85    /// that has meshes attached, and this is the format the harness compares
86    /// through.
87    pub triangulations: bool,
88}
89
90impl Default for WriteOptions {
91    fn default() -> Self {
92        Self {
93            triangulations: true,
94        }
95    }
96}
97
98/// A fresh model holding only what `roots` reach, with the roots in it.
99///
100/// Every operation adds its results to a model, and one that fails or is
101/// retried leaves what it built along the way, unreachable. A long session
102/// that has run many operations carries all of that. The native
103/// format writes exactly the reachable closure of its roots, so writing and
104/// reading back is a compaction: the same shapes, same geometry, same
105/// identities for what survives, in a model with nothing else in it. Of the
106/// provenance it keeps the entries those identities name and their
107/// ancestry; the ids of the rest stay issued and resolve to nothing.
108///
109/// The handles come back renumbered. To compact a model and keep the
110/// handles into it, use [`Model::retain_reachable`].
111///
112/// # Errors
113///
114/// As [`write()`] and [`read()`].
115pub fn compacted(model: &Model, roots: &[Shape]) -> OgeomResult<(Model, Vec<Shape>)> {
116    read(&write(model, roots, WriteOptions::default())?)
117}
118
119/// Write a model, and the shapes it is a document *about*.
120///
121/// `roots` are recorded so a reader gets back the same handles the writer had.
122/// A model with no roots is legal (it is a document of loose geometry), but a
123/// reader then has nothing to start a traversal from.
124///
125/// With roots, the file holds what they reach: their nodes, geometry and
126/// placements, and the provenance entries of the identities those nodes
127/// carry with every entity those entries derive from. Every lineage query
128/// on the written shapes answers as it did in `model`. With no roots, the
129/// whole model is written.
130///
131/// # Errors
132///
133/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the model holds
134/// something this does not know how to write, which is refused rather than
135/// dropped, or if a root does not resolve in the model.
136pub fn write(model: &Model, roots: &[Shape], options: WriteOptions) -> OgeomResult<String> {
137    write_renumbered(model, roots, options).map(|(text, _)| text)
138}
139
140/// [`write()`], and where the model's nodes landed in the file.
141fn write_renumbered(
142    model: &Model,
143    roots: &[Shape],
144    options: WriteOptions,
145) -> OgeomResult<(String, Renumbered)> {
146    for root in roots {
147        if model.node(root).is_none() {
148            ogeom_bail!(Construction, "a root shape is not in this model");
149        }
150    }
151    // The file carries the reachable closure of `roots`, not the model: a
152    // snapshot of one body from a large assembly is that body's records,
153    // not everyone else's. Empty roots keep the whole
154    // model; that is `write_document`'s contract, whose products name
155    // shapes the root list does not. When the closure covers every node and
156    // every piece of geometry, the subset would be a copy of the model
157    // spelled the long way, so the model itself is written and the handles
158    // keep their numbers.
159    //
160    // Provenance is kept the same way: the entries of the identities the
161    // closure's nodes carry and the ancestry those entries name, which is
162    // what a lineage query on the written shapes walks. A model that ran
163    // many operations holds the entries of every intermediate result, and
164    // none of those are anyone's ancestry.
165    //
166    // The closure is walked from the roots and looked up by handle, so the
167    // cost follows what is written, not what the model has accumulated.
168    //
169    // A file numbers its records 0, 1, 2, ...; a model that has dropped
170    // entries (`Model::retain_reachable`) has gaps between its handles, so
171    // it is written as a subset of itself, renumbered, even when the
172    // subset is all of it.
173    let closure = if roots.is_empty() {
174        if gapless(model) {
175            let entries: Vec<(EntityId, &Provenance)> = model.provenance().iter().collect();
176            return Ok((
177                write_model(model, roots, options, &entries)?,
178                Renumbered(None),
179            ));
180        }
181        Closure::everything(model)
182    } else {
183        closure_of(model, roots)
184    };
185    let entries = closure.entries(model);
186    if !roots.is_empty() && closure.covers(model) && gapless(model) {
187        return Ok((
188            write_model(model, roots, options, &entries)?,
189            Renumbered(None),
190        ));
191    }
192    let (parts, roots, node_map) = subset_parts(model, roots, &closure)?;
193    let records = Records {
194        scale: parts.tolerances.scale(),
195        datums: parts
196            .datums
197            .iter()
198            .enumerate()
199            .map(|(i, datum)| (Key::from_parts(dense(i), 0), *datum)),
200        geometry: &parts.geometry,
201        entries: &entries,
202        nodes: parts
203            .nodes
204            .iter()
205            .enumerate()
206            .map(|(i, node)| (Key::from_parts(dense(i), 0), node)),
207        identities: parts.identity.iter().copied(),
208        current_op: parts.current_op,
209    };
210    Ok((
211        emit_records(records, &roots, options)?,
212        Renumbered(Some(node_map)),
213    ))
214}
215
216/// Where a written model's nodes landed in the file: under their own
217/// handles, or renumbered by node index where the model was written as a
218/// subset of itself.
219struct Renumbered(Option<ogeom_core::FastMap<u32, u32>>);
220
221impl Renumbered {
222    /// The handle a node is written under.
223    fn node(&self, id: TShapeId) -> OgeomResult<TShapeId> {
224        match &self.0 {
225            None => Ok(id),
226            Some(map) => map
227                .get(&id.index())
228                .map(|&new| Key::from_parts(new, 0))
229                .ok_or_else(|| {
230                    ogeom_core::ogeom_err!(
231                        Dangling,
232                        "a document record names a node its model no longer holds"
233                    )
234                }),
235        }
236    }
237
238    /// The shape a shape is written as.
239    fn shape(&self, shape: &Shape) -> OgeomResult<Shape> {
240        Ok(Shape::new(
241            self.node(shape.node())?,
242            shape.location().clone(),
243            shape.orientation(),
244        ))
245    }
246}
247
248/// Whether the model's nodes and geometry sit at indices 0, 1, 2, ... at
249/// generation zero, as a file's records are numbered.
250fn gapless(model: &Model) -> bool {
251    fn run<T>(mut keys: impl Iterator<Item = Key<T>>) -> bool {
252        keys.by_ref()
253            .enumerate()
254            .all(|(i, key)| key.index() as usize == i && key.generation() == 0)
255    }
256    let geometry = model.geometry();
257    run(model.nodes().map(|(id, _)| id))
258        && run(geometry.curves().map(|(id, _)| id))
259        && run(geometry.pcurves().map(|(id, _)| id))
260        && run(geometry.surfaces().map(|(id, _)| id))
261        && run(geometry.triangulations().map(|(id, _)| id))
262}
263
264/// A dense index for an entry of a subset. The source arena held it as
265/// u32, and a subset is no larger, so the fit is by construction.
266fn dense(n: usize) -> u32 {
267    u32::try_from(n).unwrap_or(u32::MAX)
268}
269
270/// Which of each arena's entries the roots reach, by handle.
271struct Closure {
272    nodes: ogeom_core::FastSet<TShapeId>,
273    datums: ogeom_core::FastSet<ogeom_topo::DatumId>,
274    curves: ogeom_core::FastSet<ogeom_topo::CurveId>,
275    pcurves: ogeom_core::FastSet<ogeom_topo::PCurveId>,
276    surfaces: ogeom_core::FastSet<ogeom_topo::SurfaceId>,
277    meshes: ogeom_core::FastSet<ogeom_topo::TriangulationId>,
278    /// The entities whose entries are written: every identity a retained
279    /// node carries and every entity those entries derive from, however far
280    /// back. Ids are untouched, so a consumer's recorded `EntityId`s stay
281    /// valid, which `provenance_and_identity_survive` holds the writer to.
282    entities: ogeom_core::FastSet<EntityId>,
283}
284
285impl Closure {
286    /// Every live entry of the model.
287    fn everything(model: &Model) -> Self {
288        let geometry = model.geometry();
289        Self {
290            nodes: model.nodes().map(|(id, _)| id).collect(),
291            datums: model.datums().iter().map(|(id, _)| id).collect(),
292            curves: geometry.curves().map(|(id, _)| id).collect(),
293            pcurves: geometry.pcurves().map(|(id, _)| id).collect(),
294            surfaces: geometry.surfaces().map(|(id, _)| id).collect(),
295            meshes: geometry.triangulations().map(|(id, _)| id).collect(),
296            entities: model.provenance().iter().map(|(id, _)| id).collect(),
297        }
298    }
299
300    /// Whether the closure keeps every node and every piece of geometry.
301    fn covers(&self, model: &Model) -> bool {
302        let (curves, pcurves, surfaces) = model.geometry().counts();
303        self.curves.len() == curves
304            && self.pcurves.len() == pcurves
305            && self.surfaces.len() == surfaces
306            && self.nodes.len() == model.nodes().count()
307    }
308
309    /// The kept entities' entries, in the order their ids were issued.
310    fn entries<'m>(&self, model: &'m Model) -> Vec<(EntityId, &'m Provenance)> {
311        let mut ids: Vec<EntityId> = self.entities.iter().copied().collect();
312        ids.sort_unstable();
313        ids.into_iter()
314            .filter_map(|id| Some((id, model.provenance().get(id)?)))
315            .collect()
316    }
317}
318
319/// Walk the roots' sub-shape graphs and note everything they touch.
320fn closure_of(model: &Model, roots: &[Shape]) -> Closure {
321    let mut c = Closure {
322        nodes: ogeom_core::FastSet::default(),
323        datums: ogeom_core::FastSet::default(),
324        curves: ogeom_core::FastSet::default(),
325        pcurves: ogeom_core::FastSet::default(),
326        surfaces: ogeom_core::FastSet::default(),
327        meshes: ogeom_core::FastSet::default(),
328        entities: ogeom_core::FastSet::default(),
329    };
330    let note_location = |datums: &mut ogeom_core::FastSet<ogeom_topo::DatumId>, l: &Location| {
331        for (datum, _) in l.chain() {
332            datums.insert(*datum);
333        }
334    };
335    let mut queue: Vec<TShapeId> = Vec::new();
336    for root in roots {
337        note_location(&mut c.datums, root.location());
338        if c.nodes.insert(root.node()) {
339            queue.push(root.node());
340        }
341    }
342    let mut ancestry: Vec<EntityId> = Vec::new();
343    while let Some(id) = queue.pop() {
344        let Some(node) = model.node_by_id(id) else {
345            continue;
346        };
347        if let Some(entity) = model.identity_of(&Shape::of(id))
348            && c.entities.insert(entity)
349        {
350            ancestry.push(entity);
351        }
352        for child in node.children() {
353            note_location(&mut c.datums, child.location());
354            if c.nodes.insert(child.node()) {
355                queue.push(child.node());
356            }
357        }
358        match node.data() {
359            NodeData::Edge(e) => {
360                for repr in &e.representations {
361                    match repr {
362                        EdgeRepr::Curve3d {
363                            curve, location, ..
364                        } => {
365                            c.curves.insert(*curve);
366                            note_location(&mut c.datums, location);
367                        }
368                        EdgeRepr::PCurve {
369                            curve,
370                            surface,
371                            location,
372                            ..
373                        } => {
374                            c.pcurves.insert(*curve);
375                            c.surfaces.insert(*surface);
376                            note_location(&mut c.datums, location);
377                        }
378                        EdgeRepr::Seam {
379                            forward,
380                            reversed,
381                            surface,
382                            location,
383                            ..
384                        } => {
385                            c.pcurves.insert(*forward);
386                            c.pcurves.insert(*reversed);
387                            c.surfaces.insert(*surface);
388                            note_location(&mut c.datums, location);
389                        }
390                        EdgeRepr::Polyline { location, .. } => {
391                            note_location(&mut c.datums, location);
392                        }
393                        EdgeRepr::PolygonOnTriangulation { triangulation, .. } => {
394                            c.meshes.insert(*triangulation);
395                        }
396                        // The enum is non-exhaustive; a representation this
397                        // walker does not know cannot name handles it should
398                        // retain, and the writer itself would refuse it first.
399                        _ => {}
400                    }
401                }
402            }
403            NodeData::Face(f) => {
404                c.surfaces.insert(f.surface);
405                note_location(&mut c.datums, &f.location);
406                if let Some(mesh) = f.triangulation {
407                    c.meshes.insert(mesh);
408                }
409            }
410            NodeData::Vertex(_) | NodeData::Container => {}
411        }
412    }
413    while let Some(entity) = ancestry.pop() {
414        if let Some(entry) = model.provenance().get(entity) {
415            for &from in entry.inputs() {
416                if c.entities.insert(from) {
417                    ancestry.push(from);
418                }
419            }
420        }
421    }
422    c
423}
424
425/// The handles of `set`, in arena order, and the map from each one's old
426/// index to its new dense one.
427fn renumbered<T>(
428    set: &ogeom_core::FastSet<Key<T>>,
429) -> (Vec<Key<T>>, ogeom_core::FastMap<u32, u32>) {
430    let mut ordered: Vec<Key<T>> = set.iter().copied().collect();
431    ordered.sort_unstable_by_key(|k| k.index());
432    let map = ordered
433        .iter()
434        .enumerate()
435        .map(|(i, k)| (k.index(), dense(i)))
436        .collect();
437    (ordered, map)
438}
439
440/// The closure rebuilt as its own parts, every handle re-densified in the
441/// model's own order, plus the roots respelled in the new numbering and
442/// each node's new index by its old one. The provenance is not among the
443/// parts: the writer takes the kept entries from the model as they are.
444fn subset_parts(
445    model: &Model,
446    roots: &[Shape],
447    closure: &Closure,
448) -> OgeomResult<(ModelParts, Vec<Shape>, ogeom_core::FastMap<u32, u32>)> {
449    let missing = || ogeom_core::ogeom_err!(Dangling, "the closure misses a referenced handle");
450    // Old index -> new dense index, in arena order, so the same model and
451    // roots write the same bytes every time.
452    let (nodes_in_order, node_map) = renumbered(&closure.nodes);
453    let (datum_ids, datum_map) = renumbered(&closure.datums);
454    let datums = datum_ids
455        .iter()
456        .map(|&id| model.datums().get(id).ok_or_else(missing))
457        .collect::<OgeomResult<Vec<_>>>()?;
458    let geometry_in = model.geometry();
459    let mut geometry = GeometryStore::new();
460    let (curve_ids, curve_map) = renumbered(&closure.curves);
461    for id in curve_ids {
462        geometry.add_curve(geometry_in.curve(id).ok_or_else(missing)?.clone());
463    }
464    let (pcurve_ids, pcurve_map) = renumbered(&closure.pcurves);
465    for id in pcurve_ids {
466        geometry.add_pcurve(geometry_in.pcurve(id).ok_or_else(missing)?.clone());
467    }
468    let (surface_ids, surface_map) = renumbered(&closure.surfaces);
469    for id in surface_ids {
470        geometry.add_surface(geometry_in.surface(id).ok_or_else(missing)?.clone());
471    }
472    let (mesh_ids, mesh_map) = renumbered(&closure.meshes);
473    for id in mesh_ids {
474        geometry.add_triangulation(geometry_in.triangulation(id).ok_or_else(missing)?.clone());
475    }
476
477    let relocate = |l: &Location| -> OgeomResult<Location> {
478        let mut out = Location::identity();
479        for (datum, power) in l.chain() {
480            let new = *datum_map.get(&datum.index()).ok_or_else(missing)?;
481            out = out.then(&Location::powered(Key::from_parts(new, 0), *power));
482        }
483        Ok(out)
484    };
485    let reshape = |s: &Shape| -> OgeomResult<Shape> {
486        let new = *node_map.get(&s.node().index()).ok_or_else(missing)?;
487        Ok(Shape::new(
488            Key::from_parts(new, 0),
489            relocate(s.location())?,
490            s.orientation(),
491        ))
492    };
493
494    let mut nodes: Vec<TShape> = Vec::new();
495    for id in &nodes_in_order {
496        let node = model.node_by_id(*id).ok_or_else(missing)?;
497        let data = match node.data() {
498            NodeData::Vertex(v) => {
499                NodeData::Vertex(VertexData::with_tolerance(v.point, v.tolerance.get())?)
500            }
501            NodeData::Edge(e) => {
502                let mut edge = EdgeData::new();
503                edge.tolerance = e.tolerance;
504                edge.degenerate = e.degenerate;
505                for repr in &e.representations {
506                    edge.add(match repr {
507                        EdgeRepr::Curve3d {
508                            curve,
509                            location,
510                            range,
511                        } => EdgeRepr::Curve3d {
512                            curve: Key::from_parts(
513                                *curve_map.get(&curve.index()).ok_or_else(missing)?,
514                                0,
515                            ),
516                            location: relocate(location)?,
517                            range: *range,
518                        },
519                        EdgeRepr::PCurve {
520                            curve,
521                            surface,
522                            location,
523                            range,
524                        } => EdgeRepr::PCurve {
525                            curve: Key::from_parts(
526                                *pcurve_map.get(&curve.index()).ok_or_else(missing)?,
527                                0,
528                            ),
529                            surface: Key::from_parts(
530                                *surface_map.get(&surface.index()).ok_or_else(missing)?,
531                                0,
532                            ),
533                            location: relocate(location)?,
534                            range: *range,
535                        },
536                        EdgeRepr::Seam {
537                            forward,
538                            reversed,
539                            surface,
540                            location,
541                            range,
542                        } => EdgeRepr::Seam {
543                            forward: Key::from_parts(
544                                *pcurve_map.get(&forward.index()).ok_or_else(missing)?,
545                                0,
546                            ),
547                            reversed: Key::from_parts(
548                                *pcurve_map.get(&reversed.index()).ok_or_else(missing)?,
549                                0,
550                            ),
551                            surface: Key::from_parts(
552                                *surface_map.get(&surface.index()).ok_or_else(missing)?,
553                                0,
554                            ),
555                            location: relocate(location)?,
556                            range: *range,
557                        },
558                        EdgeRepr::Polyline {
559                            points,
560                            parameters,
561                            location,
562                            deflection,
563                        } => EdgeRepr::Polyline {
564                            points: points.clone(),
565                            parameters: parameters.clone(),
566                            location: relocate(location)?,
567                            deflection: *deflection,
568                        },
569                        EdgeRepr::PolygonOnTriangulation {
570                            triangulation,
571                            indices,
572                            location,
573                        } => EdgeRepr::PolygonOnTriangulation {
574                            triangulation: Key::from_parts(
575                                *mesh_map.get(&triangulation.index()).ok_or_else(missing)?,
576                                0,
577                            ),
578                            indices: indices.clone(),
579                            location: relocate(location)?,
580                        },
581                        other => ogeom_bail!(
582                            Construction,
583                            "an edge representation this writer does not know: {other:?}"
584                        ),
585                    });
586                }
587                edge.assert_same_parameter(e.same_parameter());
588                NodeData::Edge(Box::new(edge))
589            }
590            NodeData::Face(f) => {
591                let surface =
592                    Key::from_parts(*surface_map.get(&f.surface.index()).ok_or_else(missing)?, 0);
593                let at = relocate(&f.location)?;
594                let mut face = if f.natural_restriction {
595                    FaceData::natural(surface, at)
596                } else {
597                    FaceData::new(surface, at)
598                };
599                face.tolerance = f.tolerance;
600                face.triangulation = match f.triangulation {
601                    Some(mesh) => Some(Key::from_parts(
602                        *mesh_map.get(&mesh.index()).ok_or_else(missing)?,
603                        0,
604                    )),
605                    None => None,
606                };
607                NodeData::Face(Box::new(face))
608            }
609            NodeData::Container => NodeData::Container,
610        };
611        let children = node
612            .children()
613            .iter()
614            .map(&reshape)
615            .collect::<OgeomResult<Vec<_>>>()?;
616        nodes.push(TShape::new(node.kind(), data, children));
617    }
618
619    let mut identity = Vec::new();
620    for (new, id) in nodes_in_order.iter().enumerate() {
621        if let Some(entity) = model.identity_of(&Shape::of(*id)) {
622            identity.push((Key::from_parts(dense(new), 0), entity));
623        }
624    }
625
626    let parts = ModelParts {
627        nodes,
628        datums,
629        geometry,
630        provenance: Vec::new(),
631        identity,
632        current_op: model.current_operation(),
633        tolerances: model.tolerances(),
634    };
635    let respelled = roots
636        .iter()
637        .map(&reshape)
638        .collect::<OgeomResult<Vec<_>>>()?;
639    Ok((parts, respelled, node_map))
640}
641
642/// Write `model` whole, with `entries` for its provenance.
643fn write_model(
644    model: &Model,
645    roots: &[Shape],
646    options: WriteOptions,
647    entries: &[(EntityId, &Provenance)],
648) -> OgeomResult<String> {
649    let records = Records {
650        scale: model.tolerances().scale(),
651        datums: model.datums().iter(),
652        geometry: model.geometry(),
653        entries,
654        nodes: model.nodes(),
655        identities: model.identities(),
656        current_op: model.current_operation(),
657    };
658    emit_records(records, roots, options)
659}
660
661/// Everything a file holds, in the order it is written.
662struct Records<'a, D, N, I> {
663    scale: f64,
664    datums: D,
665    geometry: &'a GeometryStore,
666    /// Ascending by id.
667    entries: &'a [(EntityId, &'a Provenance)],
668    nodes: N,
669    identities: I,
670    current_op: OpId,
671}
672
673/// The text of `records` and `roots`, at the oldest version that holds it.
674fn emit_records<'a, D, N, I>(
675    records: Records<'a, D, N, I>,
676    roots: &[Shape],
677    options: WriteOptions,
678) -> OgeomResult<String>
679where
680    D: Iterator<Item = (ogeom_topo::DatumId, ogeom_topo::Datum)>,
681    N: Iterator<Item = (TShapeId, &'a TShape)>,
682    I: Iterator<Item = (TShapeId, EntityId)>,
683{
684    // A table with gaps in its ids is what version 3 added; a file without
685    // them is written as version 2, which every reader since then follows.
686    let gapped = records
687        .entries
688        .iter()
689        .enumerate()
690        .any(|(i, (id, _))| id.get() != i as u64 + 1);
691    let mut out = String::new();
692    out.push_str(&format!(
693        "{MAGIC} {}\n",
694        if gapped { VERSION } else { GAPLESS_VERSION }
695    ));
696
697    // The unit scale, first, because everything after it is measured in those
698    // units. A document that did not record it would read back correctly and be
699    // *validated* against whatever the reader assumed, so geometry legitimate
700    // at one scale could be refused at another, and nothing would say why.
701    let mut t = Tokens::default();
702    w(&mut t, "units");
703    n(&mut t, records.scale);
704    emit(&mut out, &t);
705
706    for (id, datum) in records.datums {
707        let mut t = Tokens::default();
708        w(&mut t, "datum");
709        key(&mut t, id);
710        transform(&mut t, &datum)?;
711        emit(&mut out, &t);
712    }
713
714    let geometry = records.geometry;
715    for (id, c) in geometry.curves() {
716        let mut t = Tokens::default();
717        w(&mut t, "curve");
718        key(&mut t, id);
719        curve(&mut t, c)?;
720        emit(&mut out, &t);
721    }
722    for (id, c) in geometry.pcurves() {
723        let mut t = Tokens::default();
724        w(&mut t, "pcurve");
725        key(&mut t, id);
726        pcurve(&mut t, c)?;
727        emit(&mut out, &t);
728    }
729    for (id, s) in geometry.surfaces() {
730        let mut t = Tokens::default();
731        w(&mut t, "surface");
732        key(&mut t, id);
733        surface(&mut t, s)?;
734        emit(&mut out, &t);
735    }
736    if options.triangulations {
737        for (id, mesh) in geometry.triangulations() {
738            write_mesh(&mut out, id, mesh);
739        }
740    } else if geometry.triangulation_count() > 0 {
741        // Said out loud in the file rather than left to be inferred from an
742        // absence: a reader comparing two documents needs to know the meshes
743        // were left out on purpose.
744        out.push_str(&format!(
745            "# {} triangulation(s) omitted by request\n",
746            geometry.triangulation_count()
747        ));
748    }
749
750    for &(id, entry) in records.entries {
751        let mut t = Tokens::default();
752        w(&mut t, "entity");
753        u(&mut t, id.get());
754        provenance(&mut t, entry);
755        emit(&mut out, &t);
756    }
757
758    for (id, node) in records.nodes {
759        write_node(&mut out, id, node, options)?;
760    }
761    for (node, entity) in records.identities {
762        let mut t = Tokens::default();
763        w(&mut t, "identity");
764        key(&mut t, node);
765        u(&mut t, entity.get());
766        emit(&mut out, &t);
767    }
768
769    let mut t = Tokens::default();
770    w(&mut t, "operation");
771    u(&mut t, u64::from(records.current_op.0));
772    emit(&mut out, &t);
773
774    for root in roots {
775        let mut t = Tokens::default();
776        w(&mut t, "root");
777        shape(&mut t, root);
778        emit(&mut out, &t);
779    }
780    Ok(out)
781}
782
783/// Read a model and its root shapes back.
784///
785/// # Errors
786///
787/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the text is not
788/// this format, names a version this does not know, or is malformed;
789/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if it names a handle that
790/// is not in the file.
791pub fn read(text: &str) -> OgeomResult<(Model, Vec<Shape>)> {
792    let (model, roots, leftover, _) = read_core(text)?;
793    if let Some(keyword) = leftover {
794        ogeom_bail!(Construction, "unknown record `{keyword}`");
795    }
796    Ok((model, roots))
797}
798
799/// Read a document's contents into an existing model.
800///
801/// Same grammar, same [`VERSION`]: this reads exactly what [`read`] reads, it
802/// just lands the result in a model that already has things in it, which is
803/// what lets a serialized tool body meet a live one in a boolean. The heavy
804/// lifting is [`Model::absorb`]: every handle shifts past what the model
805/// already holds, identities land under new ids, and the returned
806/// [`Absorbed`] says where, so references recorded against the source
807/// document still resolve.
808///
809/// A document authored at another unit scale is refused, not rescaled.
810///
811/// # Errors
812///
813/// As [`read`], plus the refusals of [`Model::absorb`].
814pub fn read_into(model: &mut Model, text: &str) -> OgeomResult<Absorbed> {
815    let (parts, roots, leftover, _, gaps) = read_parts(text)?;
816    if let Some(keyword) = leftover {
817        ogeom_bail!(Construction, "unknown record `{keyword}`");
818    }
819    let absorbed = model.absorb(parts, &roots)?;
820    model.forget_provenance(
821        gaps.iter()
822            .filter_map(|id| absorbed.entities.get(id).copied()),
823    );
824    Ok(absorbed)
825}
826
827/// The model section, stopping at the first record it does not know.
828///
829/// Returns the leftover keyword and the cursor standing just past it, so a
830/// layered reader (the document's) can pick up where the model's ends.
831#[allow(clippy::type_complexity)]
832fn read_core(text: &str) -> OgeomResult<(Model, Vec<Shape>, Option<String>, Cursor<'_>)> {
833    let (parts, roots, leftover, cursor, gaps) = read_parts(text)?;
834    let mut model = Model::from_parts(parts)?;
835    model.forget_provenance(gaps);
836    // The roots were rebuilt alongside the rest but travel outside `ModelParts`,
837    // so they are still unbound: they name no arena and resolve nowhere. Binding
838    // them checks them too, which is why a file naming a root that is not there
839    // fails here rather than in whatever first tries to use it.
840    let roots = roots
841        .iter()
842        .map(|root| model.bind(root))
843        .collect::<OgeomResult<Vec<_>>>()?;
844    Ok((model, roots, leftover, cursor))
845}
846
847/// The record loop: text to [`ModelParts`] and unbound roots.
848///
849/// Everything [`read_core`] does short of assembling a model, split out so
850/// [`read_into`] can hand the same parts to [`Model::absorb`] instead.
851///
852/// The last element is the entity ids the file skipped. Their places in
853/// the parts' table hold bare primitives so the numbering holds, and the
854/// caller forgets them once the model is assembled.
855#[allow(clippy::type_complexity)]
856fn read_parts(
857    text: &str,
858) -> OgeomResult<(
859    ModelParts,
860    Vec<Shape>,
861    Option<String>,
862    Cursor<'_>,
863    Vec<EntityId>,
864)> {
865    let mut cursor = Cursor::new(text);
866    let mut leftover = None;
867
868    if cursor.word()? != MAGIC {
869        ogeom_bail!(Construction, "not an ogeom document");
870    }
871    let version = cursor.count()?;
872    if version == 0 || version > VERSION as usize {
873        ogeom_bail!(
874            Construction,
875            "document is version {version}; this reads versions 1 through \
876             {VERSION}"
877        );
878    }
879
880    // The scale is read before anything it measures, so every geometric check
881    // below runs against the document's own units rather than an assumption.
882    let tol = read_units(&mut cursor)?;
883    let mut parts = ModelParts {
884        tolerances: tol,
885        ..ModelParts::default()
886    };
887    let mut geometry = GeometryStore::new();
888    let mut roots = Vec::new();
889    let mut gaps: Vec<EntityId> = Vec::new();
890
891    while !cursor.done() {
892        let tag = cursor.word()?.to_string();
893        match tag.as_str() {
894            "datum" => {
895                let key = cursor.key()?;
896                expect_datum(key, parts.datums.len())?;
897                parts.datums.push(cursor.transform(tol)?);
898            }
899            "curve" => {
900                let (index, _) = cursor.key()?;
901                expect_index(index, geometry.counts().0)?;
902                geometry.add_curve(cursor.curve(tol)?);
903            }
904            "pcurve" => {
905                let (index, _) = cursor.key()?;
906                expect_index(index, geometry.counts().1)?;
907                geometry.add_pcurve(cursor.pcurve(tol)?);
908            }
909            "surface" => {
910                let (index, _) = cursor.key()?;
911                expect_index(index, geometry.counts().2)?;
912                geometry.add_surface(cursor.surface(tol)?);
913            }
914            "mesh" => {
915                let (index, _) = cursor.key()?;
916                expect_index(index, geometry.triangulation_count())?;
917                geometry.add_triangulation(cursor.mesh()?);
918            }
919            "entity" => {
920                let id = cursor.count()?;
921                let next = parts.provenance.len() + 1;
922                if id < next || (version < 3 && id != next) {
923                    ogeom_bail!(
924                        Construction,
925                        "entities must be written in the order their identities \
926                         were issued; expected {next}, got {id}"
927                    );
928                }
929                for skipped in next..id {
930                    let Some(gap) = EntityId::from_raw(skipped as u64) else {
931                        ogeom_bail!(Construction, "identity 0 was never issued");
932                    };
933                    gaps.push(gap);
934                    parts.provenance.push(Provenance::Primitive {
935                        op: OpId(0),
936                        role: Role::SOLE,
937                    });
938                }
939                let entry = cursor.provenance()?;
940                if let Some(from) = entry
941                    .inputs()
942                    .iter()
943                    .find(|from| gaps.binary_search(from).is_ok())
944                {
945                    ogeom_bail!(
946                        Construction,
947                        "entity {id} is derived from entity {}, which the file left out",
948                        from.get()
949                    );
950                }
951                parts.provenance.push(entry);
952            }
953            "node" => {
954                let (index, _) = cursor.key()?;
955                expect_index(index, parts.nodes.len())?;
956                parts.nodes.push(cursor.node()?);
957            }
958            "identity" => {
959                let node = cursor.shape_key()?;
960                let raw = cursor.count()?;
961                let Some(entity) = EntityId::from_raw(raw as u64) else {
962                    ogeom_bail!(Construction, "identity 0 was never issued");
963                };
964                parts.identity.push((node, entity));
965            }
966            "operation" => parts.current_op = OpId(cursor.small()?),
967            "root" => roots.push(cursor.shape()?),
968            other => {
969                leftover = Some(other.to_string());
970                break;
971            }
972        }
973    }
974
975    if let Some((_, entity)) = parts
976        .identity
977        .iter()
978        .find(|(_, entity)| gaps.binary_search(entity).is_ok())
979    {
980        ogeom_bail!(
981            Construction,
982            "a node carries entity {}, which the file left out",
983            entity.get()
984        );
985    }
986
987    parts.geometry = geometry;
988    Ok((parts, roots, leftover, cursor, gaps))
989}
990
991/// Read the `units` record, which every document since version 1 carries.
992fn read_units(cursor: &mut Cursor<'_>) -> OgeomResult<Tolerances> {
993    if cursor.word()? != "units" {
994        ogeom_bail!(
995            Construction,
996            "a document must say what units it is in before anything measured \
997             in them"
998        );
999    }
1000    Tolerances::with_scale(cursor.number()?)
1001}
1002
1003/// Refuse a record written out of arena order.
1004///
1005/// The order *is* the numbering (a reader replays inserts to reproduce the
1006/// handles), so a gap or a repeat would silently shift every later reference.
1007fn expect_index(index: u32, next: usize) -> OgeomResult<()> {
1008    if index as usize != next {
1009        ogeom_bail!(
1010            Construction,
1011            "records must run in arena order; expected index {next}, got {index}"
1012        );
1013    }
1014    Ok(())
1015}
1016
1017/// As [`expect_index`], for a datum, whose generation must be a fresh arena's.
1018fn expect_datum(key: (u32, u32), next: usize) -> OgeomResult<()> {
1019    expect_index(key.0, next)?;
1020    if key.1 != 0 {
1021        ogeom_bail!(
1022            Construction,
1023            "a datum with generation {} cannot be rebuilt: a fresh arena hands \
1024             out generation 0, so the handle would not match what the file says",
1025            key.1
1026        );
1027    }
1028    Ok(())
1029}
1030
1031// --- writing -----------------------------------------------------------------
1032
1033// --- the document layer -------------------------------------------------------
1034
1035/// Write a document: the model, then the product structure, appearance,
1036/// names and PMI over it.
1037///
1038/// The model section is exactly what [`write()`] emits, so a model-only reader
1039/// stops cleanly at the document records; writing what [`read_document`]
1040/// read reproduces the bytes, the same contract the model layer keeps.
1041///
1042/// # Errors
1043///
1044/// As [`write()`].
1045pub fn write_document(
1046    document: &ogeom_doc::Document,
1047    options: WriteOptions,
1048) -> OgeomResult<String> {
1049    let (mut out, renumbered) = write_renumbered(document.model(), &[], options)?;
1050
1051    for (_, product) in document.products() {
1052        let mut t = Tokens::default();
1053        w(&mut t, "product");
1054        text(&mut t, &product.name);
1055        match product.colour {
1056            Some(c) => {
1057                flag(&mut t, true);
1058                for channel in [c.r, c.g, c.b, c.a] {
1059                    n(&mut t, channel);
1060                }
1061            }
1062            None => flag(&mut t, false),
1063        }
1064        match &product.kind {
1065            ogeom_doc::ProductKind::Part { shape: part } => {
1066                w(&mut t, "part");
1067                shape(&mut t, &renumbered.shape(part)?);
1068            }
1069            ogeom_doc::ProductKind::Assembly { children } => {
1070                w(&mut t, "assembly");
1071                u(&mut t, children.len() as u64);
1072                for instance in children {
1073                    u(&mut t, u64::from(instance.product.index()));
1074                    location(&mut t, &instance.location);
1075                    match &instance.name {
1076                        Some(name) => text(&mut t, name),
1077                        None => w(&mut t, "-"),
1078                    }
1079                }
1080            }
1081        }
1082        emit(&mut out, &t);
1083    }
1084
1085    let mut colours: Vec<_> = document.colours().collect();
1086    colours.sort_by_key(|(node, _)| (node.index(), node.generation()));
1087    for (node, colour) in colours {
1088        let mut t = Tokens::default();
1089        w(&mut t, "doc-colour");
1090        key(&mut t, renumbered.node(node)?);
1091        for channel in [colour.r, colour.g, colour.b, colour.a] {
1092            n(&mut t, channel);
1093        }
1094        emit(&mut out, &t);
1095    }
1096    let mut names: Vec<_> = document.names().collect();
1097    names.sort_by_key(|(node, _)| (node.index(), node.generation()));
1098    for (node, name) in names {
1099        let mut t = Tokens::default();
1100        w(&mut t, "doc-name");
1101        key(&mut t, renumbered.node(node)?);
1102        text(&mut t, name);
1103        emit(&mut out, &t);
1104    }
1105
1106    let pmi = document.pmi();
1107    for dimension in &pmi.dimensions {
1108        let mut t = Tokens::default();
1109        w(&mut t, "pmi-dim");
1110        text(&mut t, &dimension.name);
1111        w(
1112            &mut t,
1113            match dimension.kind {
1114                ogeom_doc::MeasureKind::Length => "L",
1115                ogeom_doc::MeasureKind::Angle => "A",
1116            },
1117        );
1118        flag(&mut t, dimension.location);
1119        optional(&mut t, dimension.plus);
1120        optional(&mut t, dimension.minus);
1121        u(&mut t, dimension.values.len() as u64);
1122        for v in &dimension.values {
1123            n(&mut t, *v);
1124        }
1125        u(&mut t, dimension.features.len() as u64);
1126        for feature in &dimension.features {
1127            u(&mut t, feature.len() as u64);
1128            for node in feature {
1129                key(&mut t, renumbered.node(*node)?);
1130            }
1131        }
1132        emit(&mut out, &t);
1133    }
1134    for tolerance in &pmi.tolerances {
1135        let mut t = Tokens::default();
1136        w(&mut t, "pmi-tol");
1137        text(&mut t, &tolerance.kind);
1138        text(&mut t, &tolerance.name);
1139        n(&mut t, tolerance.magnitude);
1140        u(&mut t, tolerance.modifiers.len() as u64);
1141        for word in &tolerance.modifiers {
1142            text(&mut t, word);
1143        }
1144        u(&mut t, tolerance.datums.len() as u64);
1145        for reference in &tolerance.datums {
1146            text(&mut t, &reference.label);
1147        }
1148        u(&mut t, tolerance.items.len() as u64);
1149        for node in &tolerance.items {
1150            key(&mut t, renumbered.node(*node)?);
1151        }
1152        emit(&mut out, &t);
1153        // The datum system and its modifiers, a record of its own after the
1154        // tolerance's, so a file with neither reads as it did.
1155        if tolerance.datum_system || tolerance.datums.iter().any(|d| !d.modifiers.is_empty()) {
1156            let mut t = Tokens::default();
1157            w(&mut t, "pmi-tol-frame");
1158            flag(&mut t, tolerance.datum_system);
1159            for reference in &tolerance.datums {
1160                u(&mut t, reference.modifiers.len() as u64);
1161                for modifier in &reference.modifiers {
1162                    match modifier {
1163                        ogeom_doc::DatumModifier::Simple(word) => {
1164                            w(&mut t, "S");
1165                            text(&mut t, word);
1166                        }
1167                        ogeom_doc::DatumModifier::WithValue { kind, value } => {
1168                            w(&mut t, "V");
1169                            w(&mut t, kind.word());
1170                            n(&mut t, *value);
1171                        }
1172                    }
1173                }
1174            }
1175            emit(&mut out, &t);
1176        }
1177    }
1178    for datum in &pmi.datums {
1179        let mut t = Tokens::default();
1180        w(&mut t, "pmi-datum");
1181        text(&mut t, &datum.label);
1182        u(&mut t, datum.items.len() as u64);
1183        for node in &datum.items {
1184            key(&mut t, renumbered.node(*node)?);
1185        }
1186        emit(&mut out, &t);
1187    }
1188
1189    for callout in &document.pmi().callouts {
1190        let mut t = Tokens::default();
1191        w(&mut t, "pmi-callout");
1192        text(&mut t, &callout.name);
1193        match &callout.plane {
1194            Some(f) => {
1195                flag(&mut t, true);
1196                frame(&mut t, f);
1197            }
1198            None => flag(&mut t, false),
1199        }
1200        u(&mut t, callout.polylines.len() as u64);
1201        for line in &callout.polylines {
1202            u(&mut t, line.len() as u64);
1203            for p in line {
1204                point(&mut t, *p);
1205            }
1206        }
1207        match callout.annotates {
1208            Some(ogeom_doc::Annotated::Dimension(i)) => {
1209                w(&mut t, "D");
1210                u(&mut t, i as u64);
1211            }
1212            Some(ogeom_doc::Annotated::Tolerance(i)) => {
1213                w(&mut t, "T");
1214                u(&mut t, i as u64);
1215            }
1216            Some(ogeom_doc::Annotated::Datum(i)) => {
1217                w(&mut t, "M");
1218                u(&mut t, i as u64);
1219            }
1220            None => w(&mut t, "-"),
1221        }
1222        emit(&mut out, &t);
1223        // What a note says and what it is about, each a record of its own
1224        // after the callout's, so a file with neither reads as it did.
1225        if let Some(words) = &callout.text {
1226            let mut t = Tokens::default();
1227            w(&mut t, "pmi-callout-text");
1228            text(&mut t, words);
1229            emit(&mut out, &t);
1230        }
1231        if !callout.items.is_empty() {
1232            let mut t = Tokens::default();
1233            w(&mut t, "pmi-callout-items");
1234            u(&mut t, callout.items.len() as u64);
1235            for node in &callout.items {
1236                key(&mut t, renumbered.node(*node)?);
1237            }
1238            emit(&mut out, &t);
1239        }
1240    }
1241    for view in document.views() {
1242        let mut t = Tokens::default();
1243        w(&mut t, "doc-view");
1244        text(&mut t, &view.name);
1245        frame(&mut t, &view.frame);
1246        match &view.clipping {
1247            Some(plane) => {
1248                flag(&mut t, true);
1249                frame(&mut t, &plane.frame());
1250            }
1251            None => flag(&mut t, false),
1252        }
1253        u(&mut t, view.callouts.len() as u64);
1254        for index in &view.callouts {
1255            u(&mut t, *index as u64);
1256        }
1257        emit(&mut out, &t);
1258    }
1259    for note in document.notes() {
1260        let mut t = Tokens::default();
1261        w(&mut t, "doc-note");
1262        text(&mut t, &note.author);
1263        text(&mut t, &note.text);
1264        match note.product {
1265            Some(id) => {
1266                flag(&mut t, true);
1267                u(&mut t, document.product_index(id) as u64);
1268            }
1269            None => flag(&mut t, false),
1270        }
1271        emit(&mut out, &t);
1272    }
1273    for item in document.construction() {
1274        let mut t = Tokens::default();
1275        w(&mut t, "doc-construction");
1276        u(&mut t, document.product_index(item.product) as u64);
1277        text(&mut t, &item.name);
1278        match &item.geometry {
1279            ogeom_doc::ConstructionGeometry::Point(p) => {
1280                w(&mut t, "point");
1281                point(&mut t, *p);
1282            }
1283            ogeom_doc::ConstructionGeometry::Axis(f) => {
1284                w(&mut t, "axis");
1285                frame(&mut t, f);
1286            }
1287            ogeom_doc::ConstructionGeometry::Curve { curve: c, range: r } => {
1288                w(&mut t, "curve");
1289                range(&mut t, *r);
1290                curve(&mut t, c)?;
1291            }
1292            ogeom_doc::ConstructionGeometry::Surface {
1293                surface: s,
1294                u: a,
1295                v: b,
1296            } => {
1297                w(&mut t, "surface");
1298                range(&mut t, *a);
1299                range(&mut t, *b);
1300                surface(&mut t, s)?;
1301            }
1302        }
1303        emit(&mut out, &t);
1304    }
1305
1306    // The attribute layer: properties, materials, layers, validation:
1307    // shape-keyed maps in key order, lists in id order, so writing what
1308    // was read gives the same bytes.
1309    let mut with_properties: Vec<_> = document.properties().collect();
1310    with_properties.sort_by_key(|(node, _)| (node.index(), node.generation()));
1311    for (node, properties) in with_properties {
1312        for property in properties {
1313            let mut t = Tokens::default();
1314            w(&mut t, "doc-prop");
1315            key(&mut t, renumbered.node(node)?);
1316            text(&mut t, &property.name);
1317            match &property.value {
1318                ogeom_doc::PropertyValue::Text(value) => {
1319                    w(&mut t, "T");
1320                    text(&mut t, value);
1321                }
1322                ogeom_doc::PropertyValue::Number(value) => {
1323                    w(&mut t, "N");
1324                    n(&mut t, *value);
1325                }
1326                ogeom_doc::PropertyValue::Flag(value) => {
1327                    w(&mut t, "F");
1328                    flag(&mut t, *value);
1329                }
1330            }
1331            emit(&mut out, &t);
1332        }
1333    }
1334    for material in document.materials() {
1335        let mut t = Tokens::default();
1336        w(&mut t, "doc-material");
1337        text(&mut t, &material.name);
1338        optional(&mut t, material.density);
1339        match material.colour {
1340            Some(c) => {
1341                flag(&mut t, true);
1342                for channel in [c.r, c.g, c.b, c.a] {
1343                    n(&mut t, channel);
1344                }
1345            }
1346            None => flag(&mut t, false),
1347        }
1348        emit(&mut out, &t);
1349    }
1350    let mut assigned: Vec<_> = document.material_assignments().collect();
1351    assigned.sort_by_key(|(node, _)| (node.index(), node.generation()));
1352    for (node, material) in assigned {
1353        let mut t = Tokens::default();
1354        w(&mut t, "doc-material-of");
1355        key(&mut t, renumbered.node(node)?);
1356        u(&mut t, material.index() as u64);
1357        emit(&mut out, &t);
1358    }
1359    for layer in document.layers() {
1360        let mut t = Tokens::default();
1361        w(&mut t, "doc-layer");
1362        text(&mut t, &layer.name);
1363        flag(&mut t, layer.visible);
1364        emit(&mut out, &t);
1365    }
1366    let mut memberships: Vec<_> = document.layer_memberships().collect();
1367    memberships.sort_by_key(|(node, _)| (node.index(), node.generation()));
1368    for (node, layers) in memberships {
1369        let mut t = Tokens::default();
1370        w(&mut t, "doc-on-layer");
1371        key(&mut t, renumbered.node(node)?);
1372        u(&mut t, layers.len() as u64);
1373        for layer in layers {
1374            u(&mut t, layer.index() as u64);
1375        }
1376        emit(&mut out, &t);
1377    }
1378    let mut checks: Vec<_> = document.validations().collect();
1379    checks.sort_by_key(|(node, _)| (node.index(), node.generation()));
1380    for (node, values) in checks {
1381        let mut t = Tokens::default();
1382        w(&mut t, "doc-check");
1383        key(&mut t, renumbered.node(node)?);
1384        n(&mut t, values.volume);
1385        n(&mut t, values.area);
1386        for v in [values.centroid.x, values.centroid.y, values.centroid.z] {
1387            n(&mut t, v);
1388        }
1389        emit(&mut out, &t);
1390    }
1391    Ok(out)
1392}
1393
1394/// Read a document back: the model plus everything over it.
1395///
1396/// # Errors
1397///
1398/// As [`read`], plus a malformed document record.
1399pub fn read_document(text: &str) -> OgeomResult<ogeom_doc::Document> {
1400    let (model, _roots, mut pending, mut cursor) = read_core(text)?;
1401    let mut document = ogeom_doc::Document::over(model);
1402    // Product ids are issued in write order, so index references bind by
1403    // re-adding in order; instances arrive after every product exists in the
1404    // file's own ordering discipline, which parts first would break, so
1405    // instances are held back and applied at the end.
1406    let mut ids: Vec<ogeom_doc::ProductId> = Vec::new();
1407    let mut instances: Vec<(usize, usize, Location, Option<String>)> = Vec::new();
1408    let mut order = 0_usize;
1409
1410    while let Some(tag) = pending.take().or_else(|| {
1411        if cursor.done() {
1412            None
1413        } else {
1414            cursor.word().ok().map(ToString::to_string)
1415        }
1416    }) {
1417        match tag.as_str() {
1418            "product" => {
1419                let name = read_text(&mut cursor)?;
1420                let colour = if cursor.flag()? {
1421                    Some(ogeom_doc::Colour {
1422                        r: cursor.number()?,
1423                        g: cursor.number()?,
1424                        b: cursor.number()?,
1425                        a: cursor.number()?,
1426                    })
1427                } else {
1428                    None
1429                };
1430                match cursor.word()? {
1431                    "part" => {
1432                        let part = document.model().bind(&cursor.shape()?)?;
1433                        let id = document.add_part(&name, part);
1434                        ids.push(id);
1435                    }
1436                    "assembly" => {
1437                        let id = document.add_assembly(&name);
1438                        ids.push(id);
1439                        let count = cursor.count()?;
1440                        for _ in 0..count {
1441                            let child = cursor.count()?;
1442                            let at = cursor.location()?;
1443                            let instance_name = match cursor.peek() {
1444                                Some("-") => {
1445                                    cursor.word()?;
1446                                    None
1447                                }
1448                                _ => Some(read_text(&mut cursor)?),
1449                            };
1450                            instances.push((order, child, at, instance_name));
1451                        }
1452                    }
1453                    other => {
1454                        ogeom_bail!(
1455                            Construction,
1456                            "a product is `part` or `assembly`, not `{other}`"
1457                        )
1458                    }
1459                }
1460                if let Some(c) = colour {
1461                    let id = ids[ids.len() - 1];
1462                    document.set_product_colour(id, c)?;
1463                }
1464                order += 1;
1465            }
1466            "doc-colour" => {
1467                let node: TShapeId = cursor.handle()?;
1468                let node = bind_node(&document, node)?;
1469                let colour = ogeom_doc::Colour {
1470                    r: cursor.number()?,
1471                    g: cursor.number()?,
1472                    b: cursor.number()?,
1473                    a: cursor.number()?,
1474                };
1475                document.set_colour(&Shape::of(node), colour);
1476            }
1477            "doc-name" => {
1478                let node: TShapeId = cursor.handle()?;
1479                let node = bind_node(&document, node)?;
1480                let name = read_text(&mut cursor)?;
1481                document.set_name(&Shape::of(node), name);
1482            }
1483            "doc-prop" => {
1484                let node: TShapeId = cursor.handle()?;
1485                let node = bind_node(&document, node)?;
1486                let name = read_text(&mut cursor)?;
1487                let value = match cursor.word()? {
1488                    "T" => ogeom_doc::PropertyValue::Text(read_text(&mut cursor)?),
1489                    "F" => ogeom_doc::PropertyValue::Flag(cursor.flag()?),
1490                    _ => ogeom_doc::PropertyValue::Number(cursor.number()?),
1491                };
1492                document.set_property(&Shape::of(node), ogeom_doc::Property { name, value });
1493            }
1494            "doc-material" => {
1495                let name = read_text(&mut cursor)?;
1496                let density = read_optional(&mut cursor)?;
1497                let colour = if cursor.flag()? {
1498                    Some(ogeom_doc::Colour {
1499                        r: cursor.number()?,
1500                        g: cursor.number()?,
1501                        b: cursor.number()?,
1502                        a: cursor.number()?,
1503                    })
1504                } else {
1505                    None
1506                };
1507                document.add_material(ogeom_doc::Material {
1508                    name,
1509                    density,
1510                    colour,
1511                });
1512            }
1513            "doc-material-of" => {
1514                let node: TShapeId = cursor.handle()?;
1515                let node = bind_node(&document, node)?;
1516                let index = cursor.count()?;
1517                let Some(id) = document.material_id(index) else {
1518                    ogeom_bail!(Construction, "material {index} is not in this document");
1519                };
1520                document.assign_material(&Shape::of(node), id);
1521            }
1522            "doc-layer" => {
1523                let name = read_text(&mut cursor)?;
1524                let visible = cursor.flag()?;
1525                let id = document.add_layer(name);
1526                document.set_layer_visible(id, visible);
1527            }
1528            "doc-on-layer" => {
1529                let node: TShapeId = cursor.handle()?;
1530                let node = bind_node(&document, node)?;
1531                for _ in 0..cursor.count()? {
1532                    let index = cursor.count()?;
1533                    let Some(id) = document.layer_id(index) else {
1534                        ogeom_bail!(Construction, "layer {index} is not in this document");
1535                    };
1536                    document.place_on_layer(&Shape::of(node), id);
1537                }
1538            }
1539            "doc-check" => {
1540                let node: TShapeId = cursor.handle()?;
1541                let node = bind_node(&document, node)?;
1542                let volume = cursor.number()?;
1543                let area = cursor.number()?;
1544                let centroid =
1545                    ogeom_math::Point::new(cursor.number()?, cursor.number()?, cursor.number()?);
1546                document.set_validation(
1547                    &Shape::of(node),
1548                    ogeom_doc::ValidationProperties {
1549                        volume,
1550                        area,
1551                        centroid,
1552                    },
1553                );
1554            }
1555            "pmi-dim" => {
1556                let name = read_text(&mut cursor)?;
1557                let kind = match cursor.word()? {
1558                    "A" => ogeom_doc::MeasureKind::Angle,
1559                    _ => ogeom_doc::MeasureKind::Length,
1560                };
1561                let location = cursor.flag()?;
1562                let plus = read_optional(&mut cursor)?;
1563                let minus = read_optional(&mut cursor)?;
1564                let mut values = Vec::new();
1565                for _ in 0..cursor.count()? {
1566                    values.push(cursor.number()?);
1567                }
1568                let mut features = Vec::new();
1569                for _ in 0..cursor.count()? {
1570                    let mut feature = Vec::new();
1571                    for _ in 0..cursor.count()? {
1572                        let node: TShapeId = cursor.handle()?;
1573                        feature.push(bind_node(&document, node)?);
1574                    }
1575                    features.push(feature);
1576                }
1577                document.pmi_mut().dimensions.push(ogeom_doc::Dimension {
1578                    name,
1579                    values,
1580                    kind,
1581                    plus,
1582                    minus,
1583                    features,
1584                    location,
1585                });
1586            }
1587            "pmi-tol" => {
1588                let kind = read_text(&mut cursor)?;
1589                let name = read_text(&mut cursor)?;
1590                let magnitude = cursor.number()?;
1591                let mut modifiers = Vec::new();
1592                for _ in 0..cursor.count()? {
1593                    modifiers.push(read_text(&mut cursor)?);
1594                }
1595                let mut datums = Vec::new();
1596                for _ in 0..cursor.count()? {
1597                    datums.push(ogeom_doc::DatumReference::new(read_text(&mut cursor)?));
1598                }
1599                let mut items = Vec::new();
1600                for _ in 0..cursor.count()? {
1601                    let node: TShapeId = cursor.handle()?;
1602                    items.push(bind_node(&document, node)?);
1603                }
1604                document
1605                    .pmi_mut()
1606                    .tolerances
1607                    .push(ogeom_doc::GeometricTolerance {
1608                        kind,
1609                        name,
1610                        magnitude,
1611                        modifiers,
1612                        datums,
1613                        datum_system: false,
1614                        items,
1615                    });
1616            }
1617            "pmi-tol-frame" => {
1618                let system = cursor.flag()?;
1619                let Some(tolerance) = document.pmi_mut().tolerances.last_mut() else {
1620                    ogeom_bail!(Construction, "a datum frame follows no tolerance");
1621                };
1622                tolerance.datum_system = system;
1623                for reference in &mut tolerance.datums {
1624                    for _ in 0..cursor.count()? {
1625                        let modifier = match cursor.word()? {
1626                            "S" => ogeom_doc::DatumModifier::Simple(read_text(&mut cursor)?),
1627                            "V" => {
1628                                let word = cursor.word()?;
1629                                let Some(kind) = ogeom_doc::DatumModifierKind::from_word(word)
1630                                else {
1631                                    ogeom_bail!(Construction, "unknown datum modifier `{word}`");
1632                                };
1633                                ogeom_doc::DatumModifier::WithValue {
1634                                    kind,
1635                                    value: cursor.number()?,
1636                                }
1637                            }
1638                            other => {
1639                                ogeom_bail!(Construction, "unknown datum modifier tag `{other}`")
1640                            }
1641                        };
1642                        reference.modifiers.push(modifier);
1643                    }
1644                }
1645            }
1646            "pmi-datum" => {
1647                let label = read_text(&mut cursor)?;
1648                let mut items = Vec::new();
1649                for _ in 0..cursor.count()? {
1650                    let node: TShapeId = cursor.handle()?;
1651                    items.push(bind_node(&document, node)?);
1652                }
1653                document
1654                    .pmi_mut()
1655                    .datums
1656                    .push(ogeom_doc::Datum { label, items });
1657            }
1658            "pmi-callout" => {
1659                let name = read_text(&mut cursor)?;
1660                let plane = if cursor.flag()? {
1661                    Some(cursor.frame(Tolerances::millimetres())?)
1662                } else {
1663                    None
1664                };
1665                let lines = cursor.count()?;
1666                let mut polylines = Vec::with_capacity(cursor.room(lines));
1667                for _ in 0..lines {
1668                    let count = cursor.count()?;
1669                    let mut line = Vec::with_capacity(cursor.room(count));
1670                    for _ in 0..count {
1671                        line.push(cursor.point()?);
1672                    }
1673                    polylines.push(line);
1674                }
1675                let annotates = match cursor.word()? {
1676                    "D" => Some(ogeom_doc::Annotated::Dimension(cursor.count()?)),
1677                    "T" => Some(ogeom_doc::Annotated::Tolerance(cursor.count()?)),
1678                    "M" => Some(ogeom_doc::Annotated::Datum(cursor.count()?)),
1679                    _ => None,
1680                };
1681                document.pmi_mut().callouts.push(ogeom_doc::Callout {
1682                    name,
1683                    plane,
1684                    polylines,
1685                    annotates,
1686                    items: Vec::new(),
1687                    text: None,
1688                });
1689            }
1690            "pmi-callout-text" => {
1691                let words = read_text(&mut cursor)?;
1692                let Some(callout) = document.pmi_mut().callouts.last_mut() else {
1693                    ogeom_bail!(Construction, "a callout's text follows no callout");
1694                };
1695                callout.text = Some(words);
1696            }
1697            "pmi-callout-items" => {
1698                let mut items = Vec::new();
1699                for _ in 0..cursor.count()? {
1700                    let node: TShapeId = cursor.handle()?;
1701                    items.push(bind_node(&document, node)?);
1702                }
1703                let Some(callout) = document.pmi_mut().callouts.last_mut() else {
1704                    ogeom_bail!(Construction, "a callout's items follow no callout");
1705                };
1706                callout.items = items;
1707            }
1708            "doc-view" => {
1709                let name = read_text(&mut cursor)?;
1710                let frame = cursor.frame(Tolerances::millimetres())?;
1711                let clipping = if cursor.flag()? {
1712                    Some(ogeom_math::Plane::new(
1713                        cursor.frame(Tolerances::millimetres())?,
1714                    ))
1715                } else {
1716                    None
1717                };
1718                let count = cursor.count()?;
1719                let mut callouts = Vec::with_capacity(cursor.room(count));
1720                for _ in 0..count {
1721                    callouts.push(cursor.count()?);
1722                }
1723                document.add_view(ogeom_doc::View {
1724                    name,
1725                    frame,
1726                    clipping,
1727                    callouts,
1728                });
1729            }
1730            "doc-note" => {
1731                let author = read_text(&mut cursor)?;
1732                let text_body = read_text(&mut cursor)?;
1733                let product = if cursor.flag()? {
1734                    let index = cursor.count()?;
1735                    ids.get(index).copied()
1736                } else {
1737                    None
1738                };
1739                document.add_note(ogeom_doc::Note {
1740                    author,
1741                    text: text_body,
1742                    product,
1743                });
1744            }
1745            "doc-construction" => {
1746                let index = cursor.count()?;
1747                let name = read_text(&mut cursor)?;
1748                let tol = Tolerances::millimetres();
1749                let geometry = match cursor.word()? {
1750                    "point" => ogeom_doc::ConstructionGeometry::Point(cursor.point()?),
1751                    "axis" => ogeom_doc::ConstructionGeometry::Axis(cursor.frame(tol)?),
1752                    "curve" => {
1753                        let range = cursor.range()?;
1754                        ogeom_doc::ConstructionGeometry::Curve {
1755                            range,
1756                            curve: cursor.curve(tol)?,
1757                        }
1758                    }
1759                    "surface" => {
1760                        let u = cursor.range()?;
1761                        let v = cursor.range()?;
1762                        ogeom_doc::ConstructionGeometry::Surface {
1763                            u,
1764                            v,
1765                            surface: cursor.surface(tol)?,
1766                        }
1767                    }
1768                    other => ogeom_bail!(
1769                        Construction,
1770                        "a construction item is a point, axis, curve or surface, not `{other}`"
1771                    ),
1772                };
1773                let Some(&product) = ids.get(index) else {
1774                    ogeom_bail!(
1775                        Dangling,
1776                        "a construction item names a product not in the file"
1777                    );
1778                };
1779                document.add_construction(product, name, geometry)?;
1780            }
1781            other => ogeom_bail!(Construction, "unknown record `{other}`"),
1782        }
1783    }
1784
1785    for (parent, child, at, name) in instances {
1786        let (Some(&parent), Some(&child)) = (ids.get(parent), ids.get(child)) else {
1787            ogeom_bail!(Dangling, "an instance names a product not in the file");
1788        };
1789        let at = document.model().bind_location(&at)?;
1790        document.add_instance_at(parent, child, at, name)?;
1791    }
1792    Ok(document)
1793}
1794
1795/// A raw node handle bound into the document's model scope.
1796fn bind_node(document: &ogeom_doc::Document, node: TShapeId) -> OgeomResult<TShapeId> {
1797    Ok(document.model().bind(&Shape::of(node))?.node())
1798}
1799
1800/// Append a text token: `'` then the percent-encoded body, so names survive
1801/// whitespace tokenization and an empty name survives at all.
1802fn text(t: &mut Tokens, s: &str) {
1803    let mut token = String::from("'");
1804    for byte in s.bytes() {
1805        if byte.is_ascii_graphic() && byte != b'%' {
1806            token.push(byte as char);
1807        } else {
1808            token.push_str(&format!("%{byte:02X}"));
1809        }
1810    }
1811    t.push(token);
1812}
1813
1814/// Read a text token back.
1815fn read_text(cursor: &mut Cursor<'_>) -> OgeomResult<String> {
1816    let token = cursor.word()?;
1817    let Some(body) = token.strip_prefix('\'') else {
1818        ogeom_bail!(Construction, "expected a text token, got `{token}`");
1819    };
1820    let mut out = Vec::new();
1821    let bytes = body.as_bytes();
1822    let mut i = 0;
1823    while i < bytes.len() {
1824        if bytes[i] == b'%' && i + 2 < bytes.len() + 1 && i + 2 < bytes.len() + 1 {
1825            let hex = body.get(i + 1..i + 3).unwrap_or("");
1826            let Ok(byte) = u8::from_str_radix(hex, 16) else {
1827                ogeom_bail!(
1828                    Construction,
1829                    "a text token escapes `%{hex}`, which is not hex"
1830                );
1831            };
1832            out.push(byte);
1833            i += 3;
1834        } else {
1835            out.push(bytes[i]);
1836            i += 1;
1837        }
1838    }
1839    String::from_utf8(out)
1840        .map_err(|_| ogeom_core::ogeom_err!(Construction, "a text token is not UTF-8"))
1841}
1842
1843/// An optional number: a presence flag then the value.
1844fn optional(t: &mut Tokens, v: Option<f64>) {
1845    match v {
1846        Some(value) => {
1847            flag(t, true);
1848            n(t, value);
1849        }
1850        None => flag(t, false),
1851    }
1852}
1853
1854/// Read an optional number back.
1855fn read_optional(cursor: &mut Cursor<'_>) -> OgeomResult<Option<f64>> {
1856    Ok(if cursor.flag()? {
1857        Some(cursor.number()?)
1858    } else {
1859        None
1860    })
1861}
1862
1863/// Append a word.
1864fn w(t: &mut Tokens, s: &str) {
1865    t.next().push_str(s);
1866}
1867
1868/// Append a number, in the shortest form that parses back to it exactly.
1869fn n(t: &mut Tokens, v: f64) {
1870    let _ = write!(t.next(), "{v:?}");
1871}
1872
1873/// Append an unsigned integer.
1874fn u(t: &mut Tokens, v: u64) {
1875    let _ = write!(t.next(), "{v}");
1876}
1877
1878/// Append an arena handle, as `index:generation`.
1879fn key<T>(t: &mut Tokens, id: Key<T>) {
1880    let _ = write!(t.next(), "{}:{}", id.index(), id.generation());
1881}
1882
1883/// Append a flag.
1884fn flag(t: &mut Tokens, v: bool) {
1885    t.next().push(if v { '1' } else { '0' });
1886}
1887
1888/// One record's tokens, joined by single spaces as they are appended.
1889///
1890/// Numbers are formatted straight into the line; writing into a `String`
1891/// cannot fail, which is why the helpers discard `write!`'s result.
1892#[derive(Default)]
1893struct Tokens(String);
1894
1895impl Tokens {
1896    /// The line, ready for the next token: a space after any token before.
1897    fn next(&mut self) -> &mut String {
1898        if !self.0.is_empty() {
1899            self.0.push(' ');
1900        }
1901        &mut self.0
1902    }
1903
1904    fn push(&mut self, token: String) {
1905        self.next().push_str(&token);
1906    }
1907}
1908
1909/// Finish a record.
1910fn emit(out: &mut String, t: &Tokens) {
1911    out.push_str(&t.0);
1912    out.push('\n');
1913}
1914
1915fn point(t: &mut Tokens, p: Point) {
1916    n(t, p.x);
1917    n(t, p.y);
1918    n(t, p.z);
1919}
1920
1921fn point2(t: &mut Tokens, p: Point2) {
1922    n(t, p.x);
1923    n(t, p.y);
1924}
1925
1926fn vector(t: &mut Tokens, v: Vector) {
1927    n(t, v.x);
1928    n(t, v.y);
1929    n(t, v.z);
1930}
1931
1932fn direction(t: &mut Tokens, d: Direction) {
1933    vector(t, d.vector());
1934}
1935
1936fn direction2(t: &mut Tokens, d: Direction2) {
1937    n(t, d.vector().x);
1938    n(t, d.vector().y);
1939}
1940
1941/// A frame, as origin then all three axes.
1942///
1943/// `y` is written even though a right-handed frame derives it, because a
1944/// mirrored frame does not: writing only `z` and `x` would quietly turn every
1945/// left-handed frame right-handed on the way back.
1946fn frame(t: &mut Tokens, f: &Frame) {
1947    point(t, f.origin());
1948    direction(t, f.x());
1949    direction(t, f.y());
1950    direction(t, f.z());
1951}
1952
1953fn frame2(t: &mut Tokens, f: &Frame2) {
1954    point2(t, f.origin());
1955    direction2(t, f.x());
1956    direction2(t, f.y());
1957}
1958
1959fn axis(t: &mut Tokens, a: Axis) {
1960    point(t, a.location);
1961    direction(t, a.direction);
1962}
1963
1964fn axis2(t: &mut Tokens, a: Axis2) {
1965    point2(t, a.location);
1966    direction2(t, a.direction);
1967}
1968
1969fn range(t: &mut Tokens, r: (f64, f64)) {
1970    n(t, r.0);
1971    n(t, r.1);
1972}
1973
1974/// A similarity transform: its unit linear part, its scale, its translation.
1975fn transform(t: &mut Tokens, x: &Transform) -> OgeomResult<()> {
1976    let m = x.linear();
1977    for row in 0..3 {
1978        for column in 0..3 {
1979            n(t, m.get(row, column)?);
1980        }
1981    }
1982    n(t, x.scale_factor());
1983    vector(t, x.translation_vector());
1984    Ok(())
1985}
1986
1987/// A placement chain, or `-` for the identity.
1988fn location(t: &mut Tokens, l: &Location) {
1989    if l.is_identity() {
1990        w(t, "-");
1991        return;
1992    }
1993    let chain: Vec<String> = l
1994        .chain()
1995        .iter()
1996        .map(|(datum, power)| format!("{}:{}^{power}", datum.index(), datum.generation()))
1997        .collect();
1998    w(t, &chain.join(","));
1999}
2000
2001/// A shape triple, as one token: node, orientation, placement.
2002fn shape(t: &mut Tokens, s: &Shape) {
2003    let orientation = match s.orientation() {
2004        Orientation::Forward => "F",
2005        Orientation::Reversed => "R",
2006        Orientation::Internal => "I",
2007        Orientation::External => "E",
2008    };
2009    let mut token = format!(
2010        "{}:{}/{orientation}",
2011        s.node().index(),
2012        s.node().generation()
2013    );
2014    if !s.location().is_identity() {
2015        let chain: Vec<String> = s
2016            .location()
2017            .chain()
2018            .iter()
2019            .map(|(datum, power)| format!("{}:{}^{power}", datum.index(), datum.generation()))
2020            .collect();
2021        token.push('/');
2022        token.push_str(&chain.join(","));
2023    }
2024    w(t, &token);
2025}
2026
2027fn knots(t: &mut Tokens, k: &KnotVector) {
2028    u(t, k.degree() as u64);
2029    u(t, k.knots().len() as u64);
2030    for value in k.knots() {
2031        n(t, *value);
2032    }
2033}
2034
2035fn weighted(t: &mut Tokens, c: Weighted<Point>) {
2036    // Written in the homogeneous form it is stored in, so no multiply and
2037    // divide stands between what was held and what comes back.
2038    point(t, c.scaled);
2039    n(t, c.weight);
2040}
2041
2042fn weighted2(t: &mut Tokens, c: Weighted<Point2>) {
2043    point2(t, c.scaled);
2044    n(t, c.weight);
2045}
2046
2047fn curve(t: &mut Tokens, c: &Curve) -> OgeomResult<()> {
2048    match c {
2049        Curve::Line(l) => {
2050            w(t, "line");
2051            axis(t, l.axis());
2052            range(t, l.domain());
2053        }
2054        Curve::Circle(c) => {
2055            w(t, "circle");
2056            frame(t, &c.circle().frame());
2057            n(t, c.circle().radius());
2058            flag(t, c.is_reversed());
2059        }
2060        Curve::Ellipse(e) => {
2061            w(t, "ellipse");
2062            frame(t, &e.ellipse().frame());
2063            n(t, e.ellipse().major_radius());
2064            n(t, e.ellipse().minor_radius());
2065            flag(t, e.is_reversed());
2066        }
2067        Curve::Hyperbola(h) => {
2068            w(t, "hyperbola");
2069            frame(t, &h.hyperbola().frame());
2070            n(t, h.hyperbola().major_radius());
2071            n(t, h.hyperbola().minor_radius());
2072            range(t, h.domain());
2073            flag(t, h.is_reversed());
2074        }
2075        Curve::Parabola(p) => {
2076            w(t, "parabola");
2077            frame(t, &p.parabola().frame());
2078            n(t, p.parabola().focal());
2079            range(t, p.domain());
2080            flag(t, p.is_reversed());
2081        }
2082        Curve::Helix(h) => {
2083            if h.taper() == 0.0 {
2084                w(t, "helix");
2085                frame(t, h.frame());
2086                n(t, h.radius());
2087                n(t, h.pitch());
2088                range(t, Curve3d::domain(h));
2089                flag(t, h.is_reversed());
2090            } else {
2091                w(t, "conical");
2092                frame(t, h.frame());
2093                n(t, h.radius());
2094                n(t, h.pitch());
2095                n(t, h.taper());
2096                range(t, Curve3d::domain(h));
2097                flag(t, h.is_reversed());
2098            }
2099        }
2100        Curve::BSpline(b) => {
2101            w(
2102                t,
2103                if b.is_periodic() {
2104                    "bspline_periodic"
2105                } else {
2106                    "bspline"
2107                },
2108            );
2109            knots(t, b.knots());
2110            u(t, b.control_points().len() as u64);
2111            for c in b.control_points() {
2112                weighted(t, *c);
2113            }
2114        }
2115        Curve::Trimmed(x) => {
2116            w(t, "trimmed");
2117            range(t, x.domain());
2118            flag(t, x.is_reversed());
2119            curve(t, x.basis())?;
2120        }
2121        Curve::Offset(o) => {
2122            w(t, "offset");
2123            n(t, o.distance());
2124            direction(t, o.reference());
2125            curve(t, o.basis())?;
2126        }
2127        Curve::OnSurface(c) => {
2128            w(t, "onsurface");
2129            pcurve(t, c.pcurve())?;
2130            surface(t, c.surface())?;
2131        }
2132    }
2133    Ok(())
2134}
2135
2136fn pcurve(t: &mut Tokens, c: &PlanarCurve) -> OgeomResult<()> {
2137    match c {
2138        PlanarCurve::Line(l) => {
2139            w(t, "line2");
2140            axis2(t, l.axis());
2141            range(t, l.domain());
2142        }
2143        PlanarCurve::Circle(c) => {
2144            w(t, "circle2");
2145            frame2(t, &c.circle().frame());
2146            n(t, c.circle().radius());
2147            flag(t, c.is_reversed());
2148        }
2149        PlanarCurve::Ellipse(e) => {
2150            w(t, "ellipse2");
2151            frame2(t, &e.ellipse().frame());
2152            n(t, e.ellipse().major_radius());
2153            n(t, e.ellipse().minor_radius());
2154            flag(t, e.is_reversed());
2155        }
2156        PlanarCurve::Hyperbola(h) => {
2157            w(t, "hyperbola2");
2158            frame2(t, &h.hyperbola().frame());
2159            n(t, h.hyperbola().major_radius());
2160            n(t, h.hyperbola().minor_radius());
2161            range(t, Curve2d::domain(h));
2162            flag(t, h.is_reversed());
2163        }
2164        PlanarCurve::BSpline(b) => {
2165            w(t, "bspline2");
2166            knots(t, b.knots());
2167            u(t, b.control_points().len() as u64);
2168            for c in b.control_points() {
2169                weighted2(t, *c);
2170            }
2171        }
2172        PlanarCurve::Trimmed(x) => {
2173            w(t, "trimmed2");
2174            range(t, x.domain());
2175            flag(t, x.is_reversed());
2176            pcurve(t, x.basis())?;
2177        }
2178        PlanarCurve::Offset(o) => {
2179            w(t, "offset2");
2180            n(t, o.distance());
2181            pcurve(t, o.basis())?;
2182        }
2183        PlanarCurve::Trig(x) => {
2184            w(t, "trig2");
2185            point2(t, x.constant());
2186            n(t, x.linear().x);
2187            n(t, x.linear().y);
2188            n(t, x.cosine().x);
2189            n(t, x.cosine().y);
2190            n(t, x.sine().x);
2191            n(t, x.sine().y);
2192            range(t, Curve2d::domain(x));
2193            flag(t, x.is_reversed());
2194        }
2195    }
2196    Ok(())
2197}
2198
2199fn surface(t: &mut Tokens, s: &SurfaceGeometry) -> OgeomResult<()> {
2200    let (u_domain, v_domain) = s.domain();
2201    match s {
2202        SurfaceGeometry::Plane(p) => {
2203            w(t, "plane");
2204            frame(t, &p.plane().frame());
2205            range(t, u_domain);
2206            range(t, v_domain);
2207        }
2208        SurfaceGeometry::Cylinder(c) => {
2209            w(t, "cylinder");
2210            frame(t, &c.cylinder().frame());
2211            n(t, c.cylinder().radius());
2212            range(t, v_domain);
2213        }
2214        SurfaceGeometry::Cone(c) => {
2215            w(t, "cone");
2216            frame(t, &c.cone().frame());
2217            n(t, c.cone().reference_radius());
2218            n(t, c.cone().half_angle());
2219            range(t, v_domain);
2220        }
2221        SurfaceGeometry::Sphere(s) => {
2222            w(t, "sphere");
2223            frame(t, &s.sphere().frame());
2224            n(t, s.sphere().radius());
2225        }
2226        SurfaceGeometry::Torus(x) => {
2227            w(t, "torus");
2228            frame(t, &x.torus().frame());
2229            n(t, x.torus().major_radius());
2230            n(t, x.torus().minor_radius());
2231        }
2232        SurfaceGeometry::BSpline(b) => {
2233            w(t, "bsurface");
2234            knots(t, b.u_knots());
2235            knots(t, b.v_knots());
2236            u(t, b.grid().u_count() as u64);
2237            u(t, b.grid().v_count() as u64);
2238            for c in b.grid().points() {
2239                weighted(t, *c);
2240            }
2241        }
2242        SurfaceGeometry::Revolution(r) => {
2243            w(t, "revolution");
2244            axis(t, r.axis());
2245            range(t, u_domain);
2246            curve(t, r.curve())?;
2247        }
2248        SurfaceGeometry::Extrusion(e) => {
2249            w(t, "extrusion");
2250            direction(t, e.direction());
2251            range(t, v_domain);
2252            curve(t, e.curve())?;
2253        }
2254        SurfaceGeometry::Trimmed(x) => {
2255            w(t, "tsurface");
2256            range(t, u_domain);
2257            range(t, v_domain);
2258            surface(t, x.basis())?;
2259        }
2260        SurfaceGeometry::Offset(o) => {
2261            w(t, "osurface");
2262            n(t, o.distance());
2263            surface(t, o.basis())?;
2264        }
2265    }
2266    Ok(())
2267}
2268
2269fn provenance(t: &mut Tokens, p: &Provenance) {
2270    match p {
2271        Provenance::Primitive { op, role } => {
2272            w(t, "primitive");
2273            u(t, u64::from(op.0));
2274            u(t, u64::from(role.0));
2275        }
2276        Provenance::Derived { op, from, role } => {
2277            w(t, "derived");
2278            u(t, u64::from(op.0));
2279            u(t, u64::from(role.0));
2280            u(t, from.len() as u64);
2281            for source in from {
2282                u(t, source.get());
2283            }
2284        }
2285        Provenance::Imported { source, external } => {
2286            w(t, "imported");
2287            u(t, u64::from(source.0));
2288            u(t, *external);
2289        }
2290    }
2291}
2292
2293/// A cached mesh: a header line, then a line per vertex and a line per triangle.
2294///
2295/// Broken across lines rather than run together, because a mesh is the largest
2296/// thing in the file by far and a one-line-per-vertex diff is the difference
2297/// between reading a disagreement and scrolling past it.
2298fn write_mesh(out: &mut String, id: ogeom_topo::TriangulationId, mesh: &Triangulation) {
2299    let mut t = Tokens::default();
2300    w(&mut t, "mesh");
2301    key(&mut t, id);
2302    u(&mut t, mesh.positions.len() as u64);
2303    u(&mut t, mesh.triangles.len() as u64);
2304    flag(&mut t, mesh.deflection_met);
2305    emit(out, &t);
2306
2307    for i in 0..mesh.positions.len() {
2308        let mut t = Tokens::default();
2309        w(&mut t, "v");
2310        point(&mut t, mesh.positions[i]);
2311        // A mesh may carry fewer normals or parameters than positions if it was
2312        // assembled by hand; the zeros keep the record rectangular and the
2313        // reader keeps whatever length it is told.
2314        vector(&mut t, mesh.normals.get(i).copied().unwrap_or(Vector::ZERO));
2315        let (u_at, v_at) = mesh.parameters.get(i).copied().unwrap_or((0.0, 0.0));
2316        n(&mut t, u_at);
2317        n(&mut t, v_at);
2318        emit(out, &t);
2319    }
2320    for triangle in &mesh.triangles {
2321        let mut t = Tokens::default();
2322        w(&mut t, "f");
2323        for index in triangle {
2324            u(&mut t, u64::from(*index));
2325        }
2326        emit(out, &t);
2327    }
2328}
2329
2330fn write_node(
2331    out: &mut String,
2332    id: TShapeId,
2333    node: &TShape,
2334    options: WriteOptions,
2335) -> OgeomResult<()> {
2336    let mut t = Tokens::default();
2337    w(&mut t, "node");
2338    key(&mut t, id);
2339    w(
2340        &mut t,
2341        match node.kind() {
2342            ShapeType::Vertex => "vertex",
2343            ShapeType::Edge => "edge",
2344            ShapeType::Wire => "wire",
2345            ShapeType::Face => "face",
2346            ShapeType::Shell => "shell",
2347            ShapeType::Solid => "solid",
2348            ShapeType::CompSolid => "compsolid",
2349            ShapeType::Compound => "compound",
2350        },
2351    );
2352
2353    let mut representations = Vec::new();
2354    match node.data() {
2355        NodeData::Vertex(v) => {
2356            n(&mut t, v.tolerance.get());
2357            point(&mut t, v.point);
2358        }
2359        NodeData::Edge(e) => {
2360            n(&mut t, e.tolerance.get());
2361            flag(&mut t, e.same_parameter());
2362            flag(&mut t, e.degenerate);
2363            // Index paths are derived data over the cached meshes; when the
2364            // meshes are omitted by request, the paths that index them go
2365            // with them rather than dangling.
2366            let kept: Vec<&EdgeRepr> = e
2367                .representations
2368                .iter()
2369                .filter(|repr| {
2370                    options.triangulations
2371                        || !matches!(repr, EdgeRepr::PolygonOnTriangulation { .. })
2372                })
2373                .collect();
2374            u(&mut t, kept.len() as u64);
2375            for repr in kept {
2376                let mut line = Tokens::default();
2377                w(&mut line, "r");
2378                write_repr(&mut line, repr)?;
2379                representations.push(line);
2380            }
2381        }
2382        NodeData::Face(f) => {
2383            n(&mut t, f.tolerance.get());
2384            key(&mut t, f.surface);
2385            location(&mut t, &f.location);
2386            flag(&mut t, f.natural_restriction);
2387            match f.triangulation.filter(|_| options.triangulations) {
2388                Some(mesh) => key(&mut t, mesh),
2389                None => w(&mut t, "-"),
2390            }
2391        }
2392        NodeData::Container => {}
2393    }
2394
2395    u(&mut t, node.children().len() as u64);
2396    for child in node.children() {
2397        shape(&mut t, child);
2398    }
2399    emit(out, &t);
2400    for line in &representations {
2401        emit(out, line);
2402    }
2403    Ok(())
2404}
2405
2406fn write_repr(t: &mut Tokens, repr: &EdgeRepr) -> OgeomResult<()> {
2407    match repr {
2408        EdgeRepr::Curve3d {
2409            curve,
2410            location: at,
2411            range: r,
2412        } => {
2413            w(t, "curve3d");
2414            key(t, *curve);
2415            location(t, at);
2416            range(t, *r);
2417        }
2418        EdgeRepr::PCurve {
2419            curve,
2420            surface,
2421            location: at,
2422            range: r,
2423        } => {
2424            w(t, "pcurve");
2425            key(t, *curve);
2426            key(t, *surface);
2427            location(t, at);
2428            range(t, *r);
2429        }
2430        EdgeRepr::Seam {
2431            forward,
2432            reversed,
2433            surface,
2434            location: at,
2435            range: r,
2436        } => {
2437            w(t, "seam");
2438            key(t, *forward);
2439            key(t, *reversed);
2440            key(t, *surface);
2441            location(t, at);
2442            range(t, *r);
2443        }
2444        EdgeRepr::Polyline {
2445            points,
2446            parameters,
2447            location: at,
2448            deflection,
2449        } => {
2450            w(t, "polyline");
2451            location(t, at);
2452            n(t, *deflection);
2453            u(t, points.len() as u64);
2454            for p in points {
2455                point(t, *p);
2456            }
2457            u(t, parameters.len() as u64);
2458            for at in parameters {
2459                n(t, *at);
2460            }
2461        }
2462        EdgeRepr::PolygonOnTriangulation {
2463            triangulation,
2464            indices,
2465            location: at,
2466        } => {
2467            w(t, "polygon-on");
2468            key(t, *triangulation);
2469            location(t, at);
2470            u(t, indices.len() as u64);
2471            for index in indices {
2472                u(t, u64::from(*index));
2473            }
2474        }
2475        // `EdgeRepr` is non-exhaustive, so a variant added later lands here.
2476        // Refused, not skipped: a file missing one of an edge's descriptions of
2477        // itself reads back as an edge that has lost a face's pcurve, and
2478        // nothing downstream would say where it went.
2479        other => ogeom_bail!(
2480            Construction,
2481            "this version writes no edge representation of that kind: {other:?}"
2482        ),
2483    }
2484    Ok(())
2485}
2486
2487// --- reading -----------------------------------------------------------------
2488
2489/// A position in the token stream.
2490///
2491/// The file is written a record to a line, but read as a flat stream of
2492/// whitespace-separated words: layout is for the person reading a diff, and
2493/// making the parser depend on it would only add a second thing to get wrong.
2494struct Cursor<'a> {
2495    items: Vec<&'a str>,
2496    at: usize,
2497}
2498
2499impl<'a> Cursor<'a> {
2500    fn new(text: &'a str) -> Self {
2501        let items = text
2502            .lines()
2503            .map(|line| line.split('#').next().unwrap_or(""))
2504            .flat_map(str::split_whitespace)
2505            .collect();
2506        Self { items, at: 0 }
2507    }
2508
2509    fn peek(&self) -> Option<&'a str> {
2510        self.items.get(self.at).copied()
2511    }
2512
2513    fn done(&self) -> bool {
2514        self.at >= self.items.len()
2515    }
2516
2517    fn word(&mut self) -> OgeomResult<&'a str> {
2518        let Some(item) = self.items.get(self.at) else {
2519            ogeom_bail!(Construction, "the document ends part-way through a record");
2520        };
2521        self.at += 1;
2522        Ok(item)
2523    }
2524
2525    fn number(&mut self) -> OgeomResult<f64> {
2526        let word = self.word()?;
2527        word.parse::<f64>()
2528            .map_err(|_| ogeom_core::ogeom_err!(Construction, "`{word}` is not a number"))
2529    }
2530
2531    /// Room to reserve for `n` items read from here on: never more than the
2532    /// words left, since every item takes at least one, so a count in the
2533    /// file cannot size an allocation past what the file holds.
2534    fn room(&self, n: usize) -> usize {
2535        n.min(self.items.len().saturating_sub(self.at))
2536    }
2537
2538    fn count(&mut self) -> OgeomResult<usize> {
2539        let word = self.word()?;
2540        word.parse::<usize>()
2541            .map_err(|_| ogeom_core::ogeom_err!(Construction, "`{word}` is not a count"))
2542    }
2543
2544    fn small(&mut self) -> OgeomResult<u32> {
2545        let word = self.word()?;
2546        word.parse::<u32>()
2547            .map_err(|_| ogeom_core::ogeom_err!(Construction, "`{word}` is not an identifier"))
2548    }
2549
2550    fn flag(&mut self) -> OgeomResult<bool> {
2551        match self.word()? {
2552            "0" => Ok(false),
2553            "1" => Ok(true),
2554            other => ogeom_bail!(Construction, "`{other}` is not a flag"),
2555        }
2556    }
2557
2558    /// An `index:generation` pair.
2559    fn key(&mut self) -> OgeomResult<(u32, u32)> {
2560        parse_key(self.word()?)
2561    }
2562
2563    fn handle<T>(&mut self) -> OgeomResult<Key<T>> {
2564        let (index, generation) = self.key()?;
2565        Ok(Key::from_parts(index, generation))
2566    }
2567
2568    fn shape_key(&mut self) -> OgeomResult<TShapeId> {
2569        self.handle()
2570    }
2571
2572    fn point(&mut self) -> OgeomResult<Point> {
2573        Ok(Point::new(self.number()?, self.number()?, self.number()?))
2574    }
2575
2576    fn point2(&mut self) -> OgeomResult<Point2> {
2577        Ok(Point2::new(self.number()?, self.number()?))
2578    }
2579
2580    fn vector(&mut self) -> OgeomResult<Vector> {
2581        Ok(Vector::new(self.number()?, self.number()?, self.number()?))
2582    }
2583
2584    /// A direction, checked rather than renormalized.
2585    ///
2586    /// Normalizing what is already a unit vector moves it by a bit or two, and
2587    /// that is enough to make writing what was read produce a different file.
2588    fn direction(&mut self, tol: Tolerances) -> OgeomResult<Direction> {
2589        Direction::unit(self.vector()?, tol)
2590    }
2591
2592    fn direction2(&mut self, tol: Tolerances) -> OgeomResult<Direction2> {
2593        Direction2::unit(
2594            ogeom_math::Vector2::new(self.number()?, self.number()?),
2595            tol,
2596        )
2597    }
2598
2599    fn frame(&mut self, tol: Tolerances) -> OgeomResult<Frame> {
2600        let origin = self.point()?;
2601        let x = self.direction(tol)?;
2602        let y = self.direction(tol)?;
2603        let z = self.direction(tol)?;
2604        Frame::from_axes(origin, x, y, z, tol)
2605    }
2606
2607    fn frame2(&mut self, tol: Tolerances) -> OgeomResult<Frame2> {
2608        let origin = self.point2()?;
2609        let x = self.direction2(tol)?;
2610        let y = self.direction2(tol)?;
2611        Frame2::from_axes(origin, x, y, tol)
2612    }
2613
2614    fn axis(&mut self, tol: Tolerances) -> OgeomResult<Axis> {
2615        Ok(Axis::new(self.point()?, self.direction(tol)?))
2616    }
2617
2618    fn axis2(&mut self, tol: Tolerances) -> OgeomResult<Axis2> {
2619        Ok(Axis2::new(self.point2()?, self.direction2(tol)?))
2620    }
2621
2622    fn range(&mut self) -> OgeomResult<(f64, f64)> {
2623        Ok((self.number()?, self.number()?))
2624    }
2625
2626    fn transform(&mut self, tol: Tolerances) -> OgeomResult<Transform> {
2627        let mut rows = [[0.0_f64; 3]; 3];
2628        for row in &mut rows {
2629            for cell in row.iter_mut() {
2630                *cell = self.number()?;
2631            }
2632        }
2633        let scale = self.number()?;
2634        let translation = self.vector()?;
2635        // Put back together from the parts a transform is stored as, rather
2636        // than multiplied into one matrix and factored apart again: the round
2637        // trip through a scale that divides out is not exact, and a placement
2638        // that drifts a little on every save is worse than one that fails.
2639        // The kind is re-derived, since it is a function of the other three.
2640        Transform::from_parts(
2641            ogeom_math::Matrix3::new(rows),
2642            scale,
2643            translation,
2644            tol.angular().max(1e-9),
2645        )
2646    }
2647
2648    fn location(&mut self) -> OgeomResult<Location> {
2649        let word = self.word()?;
2650        parse_location(word)
2651    }
2652
2653    fn shape(&mut self) -> OgeomResult<Shape> {
2654        let word = self.word()?;
2655        let mut parts = word.split('/');
2656        let (Some(node), Some(orientation)) = (parts.next(), parts.next()) else {
2657            ogeom_bail!(Construction, "`{word}` is not a shape");
2658        };
2659        let (index, generation) = parse_key(node)?;
2660        let orientation = match orientation {
2661            "F" => Orientation::Forward,
2662            "R" => Orientation::Reversed,
2663            "I" => Orientation::Internal,
2664            "E" => Orientation::External,
2665            other => ogeom_bail!(Construction, "`{other}` is not an orientation"),
2666        };
2667        let location = match parts.next() {
2668            Some(chain) => parse_location(chain)?,
2669            None => Location::identity(),
2670        };
2671        if parts.next().is_some() {
2672            ogeom_bail!(Construction, "`{word}` has more parts than a shape has");
2673        }
2674        Ok(Shape::new(
2675            Key::from_parts(index, generation),
2676            location,
2677            orientation,
2678        ))
2679    }
2680
2681    fn knots(&mut self) -> OgeomResult<KnotVector> {
2682        let degree = self.count()?;
2683        let n = self.count()?;
2684        let mut values = Vec::with_capacity(self.room(n));
2685        for _ in 0..n {
2686            values.push(self.number()?);
2687        }
2688        KnotVector::new(values, degree)
2689    }
2690
2691    fn weighted(&mut self) -> OgeomResult<Weighted<Point>> {
2692        Ok(Weighted {
2693            scaled: self.point()?,
2694            weight: self.number()?,
2695        })
2696    }
2697
2698    fn weighted2(&mut self) -> OgeomResult<Weighted<Point2>> {
2699        Ok(Weighted {
2700            scaled: self.point2()?,
2701            weight: self.number()?,
2702        })
2703    }
2704
2705    fn curve(&mut self, tol: Tolerances) -> OgeomResult<Curve> {
2706        Ok(match self.word()? {
2707            "line" => {
2708                let axis = self.axis(tol)?;
2709                let (lo, hi) = self.range()?;
2710                LineCurve::over(axis, lo, hi)?.into()
2711            }
2712            "circle" => {
2713                let circle = Circle::new(self.frame(tol)?, self.number()?, tol)?;
2714                reverse_if(CircleCurve::new(circle).into(), self.flag()?)
2715            }
2716            "ellipse" => {
2717                let frame = self.frame(tol)?;
2718                let ellipse = Ellipse::new(frame, self.number()?, self.number()?, tol)?;
2719                reverse_if(EllipseCurve::new(ellipse).into(), self.flag()?)
2720            }
2721            "hyperbola" => {
2722                let frame = self.frame(tol)?;
2723                let h = Hyperbola::new(frame, self.number()?, self.number()?, tol)?;
2724                let (lo, hi) = self.range()?;
2725                let curve = HyperbolaCurve::over(h, lo, hi)?;
2726                reverse_if(curve.into(), self.flag()?)
2727            }
2728            "parabola" => {
2729                let frame = self.frame(tol)?;
2730                let p = Parabola::new(frame, self.number()?, tol)?;
2731                let (lo, hi) = self.range()?;
2732                let curve = ParabolaCurve::over(p, lo, hi)?;
2733                reverse_if(curve.into(), self.flag()?)
2734            }
2735            "helix" => {
2736                let frame = self.frame(tol)?;
2737                let radius = self.number()?;
2738                let pitch = self.number()?;
2739                let (lo, hi) = self.range()?;
2740                let curve = HelixCurve::over(frame, radius, pitch, lo, hi)?;
2741                reverse_if(curve.into(), self.flag()?)
2742            }
2743            "conical" => {
2744                let frame = self.frame(tol)?;
2745                let radius = self.number()?;
2746                let pitch = self.number()?;
2747                let taper = self.number()?;
2748                let (lo, hi) = self.range()?;
2749                let curve = HelixCurve::conical(frame, radius, pitch, taper, lo, hi)?;
2750                reverse_if(curve.into(), self.flag()?)
2751            }
2752            "bspline" => {
2753                let knots = self.knots()?;
2754                let n = self.count()?;
2755                let mut control = Vec::with_capacity(self.room(n));
2756                for _ in 0..n {
2757                    control.push(self.weighted()?);
2758                }
2759                BSplineCurve::rational(knots, control)?.into()
2760            }
2761            "bspline_periodic" => {
2762                let knots = self.knots()?;
2763                let n = self.count()?;
2764                let mut control = Vec::with_capacity(self.room(n));
2765                for _ in 0..n {
2766                    control.push(self.weighted()?);
2767                }
2768                BSplineCurve::periodic_from_parts(knots, control, tol)?.into()
2769            }
2770            "trimmed" => {
2771                let (lo, hi) = self.range()?;
2772                let reversed = self.flag()?;
2773                let basis = self.curve(tol)?;
2774                let curve = TrimmedCurve::new(basis, lo, hi, tol)?;
2775                reverse_if(Curve::Trimmed(Box::new(curve)), reversed)
2776            }
2777            "offset" => {
2778                let distance = self.number()?;
2779                let reference = self.direction(tol)?;
2780                let basis = self.curve(tol)?;
2781                Curve::Offset(Box::new(ogeom_geom::OffsetCurve::new(
2782                    basis, distance, reference,
2783                )?))
2784            }
2785            "onsurface" => {
2786                let pcurve = self.pcurve(tol)?;
2787                let surface = self.surface(tol)?;
2788                Curve::OnSurface(Box::new(ogeom_geom::CurveOnSurface::new(pcurve, surface)))
2789            }
2790            other => ogeom_bail!(Construction, "`{other}` is not a curve this reads"),
2791        })
2792    }
2793
2794    fn pcurve(&mut self, tol: Tolerances) -> OgeomResult<PlanarCurve> {
2795        Ok(match self.word()? {
2796            "line2" => {
2797                let axis = self.axis2(tol)?;
2798                let (lo, hi) = self.range()?;
2799                Line2d::over(axis, lo, hi)?.into()
2800            }
2801            "circle2" => {
2802                let circle = Circle2::new(self.frame2(tol)?, self.number()?, tol)?;
2803                reverse_if(Circle2d::new(circle).into(), self.flag()?)
2804            }
2805            "ellipse2" => {
2806                let frame = self.frame2(tol)?;
2807                let ellipse = Ellipse2::new(frame, self.number()?, self.number()?, tol)?;
2808                reverse_if(Ellipse2d::new(ellipse).into(), self.flag()?)
2809            }
2810            "hyperbola2" => {
2811                let frame = self.frame2(tol)?;
2812                let hyperbola = Hyperbola2::new(frame, self.number()?, self.number()?, tol)?;
2813                let (lo, hi) = self.range()?;
2814                let curve = ogeom_geom::Hyperbola2d::over(hyperbola, lo, hi)?;
2815                reverse_if(curve.into(), self.flag()?)
2816            }
2817            "bspline2" => {
2818                let knots = self.knots()?;
2819                let n = self.count()?;
2820                let mut control = Vec::with_capacity(self.room(n));
2821                for _ in 0..n {
2822                    control.push(self.weighted2()?);
2823                }
2824                BSpline2d::rational(knots, control)?.into()
2825            }
2826            "trimmed2" => {
2827                let (lo, hi) = self.range()?;
2828                let reversed = self.flag()?;
2829                let basis = self.pcurve(tol)?;
2830                let curve = Trimmed2d::new(basis, lo, hi, tol)?;
2831                reverse_if(PlanarCurve::Trimmed(Box::new(curve)), reversed)
2832            }
2833            "offset2" => {
2834                let distance = self.number()?;
2835                let basis = self.pcurve(tol)?;
2836                PlanarCurve::Offset(Box::new(ogeom_geom::Offset2d::new(basis, distance)?))
2837            }
2838            "trig2" => {
2839                let c = self.point2()?;
2840                let d = ogeom_math::Vector2::new(self.number()?, self.number()?);
2841                let a = ogeom_math::Vector2::new(self.number()?, self.number()?);
2842                let b = ogeom_math::Vector2::new(self.number()?, self.number()?);
2843                let (lo, hi) = self.range()?;
2844                let curve = ogeom_geom::Trig2d::new(c, d, a, b, (lo, hi))?;
2845                reverse_if(PlanarCurve::Trig(curve), self.flag()?)
2846            }
2847            other => ogeom_bail!(Construction, "`{other}` is not a planar curve this reads"),
2848        })
2849    }
2850
2851    fn surface(&mut self, tol: Tolerances) -> OgeomResult<SurfaceGeometry> {
2852        Ok(match self.word()? {
2853            "plane" => {
2854                let plane = Plane::new(self.frame(tol)?);
2855                PlaneSurface::over(plane, self.range()?, self.range()?)?.into()
2856            }
2857            "cylinder" => {
2858                let cylinder = Cylinder::new(self.frame(tol)?, self.number()?, tol)?;
2859                CylinderSurface::new(cylinder, self.range()?)?.into()
2860            }
2861            "cone" => {
2862                let frame = self.frame(tol)?;
2863                let cone = Cone::new(frame, self.number()?, self.number()?, tol)?;
2864                ConeSurface::new(cone, self.range()?)?.into()
2865            }
2866            "sphere" => {
2867                let sphere = Sphere::new(self.frame(tol)?, self.number()?, tol)?;
2868                SphereSurface::new(sphere).into()
2869            }
2870            "torus" => {
2871                let frame = self.frame(tol)?;
2872                let torus = Torus::new(frame, self.number()?, self.number()?, tol)?;
2873                TorusSurface::new(torus).into()
2874            }
2875            "bsurface" => {
2876                let u_knots = self.knots()?;
2877                let v_knots = self.knots()?;
2878                let u_count = self.count()?;
2879                let v_count = self.count()?;
2880                let Some(cells) = u_count.checked_mul(v_count) else {
2881                    ogeom_bail!(Construction, "a {u_count} by {v_count} control grid");
2882                };
2883                let mut points = Vec::with_capacity(self.room(cells));
2884                for _ in 0..cells {
2885                    points.push(self.weighted()?);
2886                }
2887                let grid = ControlGrid::new(points, u_count, v_count)?;
2888                BSplineSurface::rational(u_knots, v_knots, grid)?.into()
2889            }
2890            "revolution" => {
2891                let axis = self.axis(tol)?;
2892                let (start, end) = self.range()?;
2893                if start != 0.0 {
2894                    ogeom_bail!(
2895                        Construction,
2896                        "a revolution's angle starts at zero; this one starts at \
2897                         {start}"
2898                    );
2899                }
2900                let basis = self.curve(tol)?;
2901                RevolutionSurface::new(basis, axis, end)?.into()
2902            }
2903            "extrusion" => {
2904                let direction = self.direction(tol)?;
2905                let extent = self.range()?;
2906                let basis = self.curve(tol)?;
2907                ExtrusionSurface::over(basis, direction, extent)?.into()
2908            }
2909            "tsurface" => {
2910                let u_range = self.range()?;
2911                let v_range = self.range()?;
2912                let basis = self.surface(tol)?;
2913                SurfaceGeometry::Trimmed(Box::new(TrimmedSurface::new(
2914                    basis, u_range, v_range, tol,
2915                )?))
2916            }
2917            "osurface" => {
2918                let distance = self.number()?;
2919                let basis = self.surface(tol)?;
2920                SurfaceGeometry::Offset(Box::new(ogeom_geom::OffsetSurface::new(basis, distance)?))
2921            }
2922            other => ogeom_bail!(Construction, "`{other}` is not a surface this reads"),
2923        })
2924    }
2925
2926    fn mesh(&mut self) -> OgeomResult<Triangulation> {
2927        let vertices = self.count()?;
2928        let triangles = self.count()?;
2929        let mut mesh = Triangulation::new();
2930        mesh.deflection_met = self.flag()?;
2931        for _ in 0..vertices {
2932            if self.word()? != "v" {
2933                ogeom_bail!(Construction, "expected a mesh vertex");
2934            }
2935            mesh.positions.push(self.point()?);
2936            mesh.normals.push(self.vector()?);
2937            mesh.parameters.push((self.number()?, self.number()?));
2938        }
2939        for _ in 0..triangles {
2940            if self.word()? != "f" {
2941                ogeom_bail!(Construction, "expected a mesh triangle");
2942            }
2943            let mut corners = [0_u32; 3];
2944            for corner in &mut corners {
2945                *corner = self.small()?;
2946                if *corner as usize >= vertices {
2947                    ogeom_bail!(
2948                        Dangling,
2949                        "a triangle names vertex {corner}, and the mesh has \
2950                         {vertices}"
2951                    );
2952                }
2953            }
2954            mesh.triangles.push(corners);
2955        }
2956        Ok(mesh)
2957    }
2958
2959    fn provenance(&mut self) -> OgeomResult<Provenance> {
2960        Ok(match self.word()? {
2961            "primitive" => Provenance::Primitive {
2962                op: OpId(self.small()?),
2963                role: Role(self.small()?),
2964            },
2965            "derived" => {
2966                let op = OpId(self.small()?);
2967                let role = Role(self.small()?);
2968                let n = self.count()?;
2969                let mut from = Vec::with_capacity(self.room(n));
2970                for _ in 0..n {
2971                    let raw = self.count()?;
2972                    let Some(id) = EntityId::from_raw(raw as u64) else {
2973                        ogeom_bail!(Construction, "identity 0 was never issued");
2974                    };
2975                    from.push(id);
2976                }
2977                Provenance::Derived {
2978                    op,
2979                    from: from.into_iter().collect(),
2980                    role,
2981                }
2982            }
2983            "imported" => Provenance::Imported {
2984                source: SourceId(self.small()?),
2985                external: self.count()? as u64,
2986            },
2987            other => ogeom_bail!(Construction, "`{other}` is not a provenance"),
2988        })
2989    }
2990
2991    fn node(&mut self) -> OgeomResult<TShape> {
2992        let kind = match self.word()? {
2993            "vertex" => ShapeType::Vertex,
2994            "edge" => ShapeType::Edge,
2995            "wire" => ShapeType::Wire,
2996            "face" => ShapeType::Face,
2997            "shell" => ShapeType::Shell,
2998            "solid" => ShapeType::Solid,
2999            "compsolid" => ShapeType::CompSolid,
3000            "compound" => ShapeType::Compound,
3001            other => ogeom_bail!(Construction, "`{other}` is not a kind of shape"),
3002        };
3003
3004        // An edge's representations are records of their own, written after the
3005        // node line, so its data cannot be finished until the children on that
3006        // line have been read past.
3007        let mut pending = None;
3008        let mut data = match kind {
3009            ShapeType::Vertex => {
3010                let tolerance = self.number()?;
3011                NodeData::Vertex(VertexData::with_tolerance(self.point()?, tolerance)?)
3012            }
3013            ShapeType::Edge => {
3014                let mut edge = EdgeData::new();
3015                edge.tolerance = Tolerance::new(self.number()?)?;
3016                let agrees = self.flag()?;
3017                edge.degenerate = self.flag()?;
3018                pending = Some((agrees, self.count()?));
3019                NodeData::Edge(Box::new(edge))
3020            }
3021            ShapeType::Face => {
3022                let tolerance = Tolerance::new(self.number()?)?;
3023                let surface = self.handle()?;
3024                let at = self.location()?;
3025                let natural = self.flag()?;
3026                let mut face = if natural {
3027                    FaceData::natural(surface, at)
3028                } else {
3029                    FaceData::new(surface, at)
3030                };
3031                face.tolerance = tolerance;
3032                face.triangulation = match self.word()? {
3033                    "-" => None,
3034                    word => Some(Key::from_parts_of(parse_key(word)?)),
3035                };
3036                NodeData::Face(Box::new(face))
3037            }
3038            _ => NodeData::Container,
3039        };
3040
3041        let n = self.count()?;
3042        let mut children = Vec::with_capacity(self.room(n));
3043        for _ in 0..n {
3044            children.push(self.shape()?);
3045        }
3046
3047        if let (Some((agrees, count)), NodeData::Edge(edge)) = (pending, &mut data) {
3048            for _ in 0..count {
3049                if self.word()? != "r" {
3050                    ogeom_bail!(Construction, "expected an edge representation");
3051                }
3052                let repr = self.repr()?;
3053                edge.add(repr);
3054            }
3055            // Set last: `add` clears it, which is the right default for a
3056            // caller attaching a representation and the wrong one for a reader
3057            // restoring a claim the document already makes.
3058            edge.assert_same_parameter(agrees);
3059        }
3060        Ok(TShape::new(kind, data, children))
3061    }
3062
3063    fn repr(&mut self) -> OgeomResult<EdgeRepr> {
3064        Ok(match self.word()? {
3065            "curve3d" => EdgeRepr::Curve3d {
3066                curve: self.handle()?,
3067                location: self.location()?,
3068                range: self.range()?,
3069            },
3070            "pcurve" => EdgeRepr::PCurve {
3071                curve: self.handle()?,
3072                surface: self.handle()?,
3073                location: self.location()?,
3074                range: self.range()?,
3075            },
3076            "seam" => EdgeRepr::Seam {
3077                forward: self.handle()?,
3078                reversed: self.handle()?,
3079                surface: self.handle()?,
3080                location: self.location()?,
3081                range: self.range()?,
3082            },
3083            "polyline" => {
3084                let location = self.location()?;
3085                let deflection = self.number()?;
3086                let n = self.count()?;
3087                let mut points = Vec::with_capacity(self.room(n));
3088                for _ in 0..n {
3089                    points.push(self.point()?);
3090                }
3091                let n = self.count()?;
3092                let mut parameters = Vec::with_capacity(self.room(n));
3093                for _ in 0..n {
3094                    parameters.push(self.number()?);
3095                }
3096                EdgeRepr::Polyline {
3097                    points,
3098                    parameters,
3099                    location,
3100                    deflection,
3101                }
3102            }
3103            "polygon-on" => {
3104                let triangulation = self.handle()?;
3105                let location = self.location()?;
3106                let n = self.count()?;
3107                let mut indices = Vec::with_capacity(self.room(n));
3108                for _ in 0..n {
3109                    let raw = self.count()?;
3110                    indices.push(u32::try_from(raw).map_err(|_| {
3111                        ogeom_core::ogeom_err!(Construction, "a mesh index does not fit u32")
3112                    })?);
3113                }
3114                EdgeRepr::PolygonOnTriangulation {
3115                    triangulation,
3116                    indices,
3117                    location,
3118                }
3119            }
3120            other => ogeom_bail!(
3121                Construction,
3122                "`{other}` is not an edge representation this reads"
3123            ),
3124        })
3125    }
3126}
3127
3128/// Reverse a curve if the file says it runs backwards.
3129fn reverse_if<T: ogeom_geom::Reversible>(curve: T, reversed: bool) -> T {
3130    if reversed { curve.reversed() } else { curve }
3131}
3132
3133fn parse_key(word: &str) -> OgeomResult<(u32, u32)> {
3134    let Some((index, generation)) = word.split_once(':') else {
3135        ogeom_bail!(Construction, "`{word}` is not a handle");
3136    };
3137    let (Ok(index), Ok(generation)) = (index.parse::<u32>(), generation.parse::<u32>()) else {
3138        ogeom_bail!(Construction, "`{word}` is not a handle");
3139    };
3140    Ok((index, generation))
3141}
3142
3143fn parse_location(word: &str) -> OgeomResult<Location> {
3144    if word == "-" {
3145        return Ok(Location::identity());
3146    }
3147    let mut location = Location::identity();
3148    for step in word.split(',') {
3149        let Some((handle, power)) = step.rsplit_once('^') else {
3150            ogeom_bail!(Construction, "`{step}` is not a placement step");
3151        };
3152        let (index, generation) = parse_key(handle)?;
3153        let Ok(power) = power.parse::<i32>() else {
3154            ogeom_bail!(Construction, "`{power}` is not a power");
3155        };
3156        let datum: DatumId = Key::from_parts(index, generation);
3157        location = location.then(&Location::powered(datum, power));
3158    }
3159    Ok(location)
3160}
3161
3162/// A handle from an already-parsed `(index, generation)` pair.
3163trait FromParsedKey: Sized {
3164    fn from_parts_of(parts: (u32, u32)) -> Self;
3165}
3166
3167impl<T> FromParsedKey for Key<T> {
3168    fn from_parts_of((index, generation): (u32, u32)) -> Self {
3169        Self::from_parts(index, generation)
3170    }
3171}
3172
3173#[cfg(test)]
3174#[allow(clippy::unwrap_used, clippy::expect_used)]
3175mod tests {
3176    use super::*;
3177    use approx::assert_relative_eq;
3178    use ogeom_algo::{
3179        check, check_tessellation, make_box, make_cone, make_cylinder, make_prism, make_revolution,
3180        make_sphere, make_torus, make_wedge,
3181    };
3182    use ogeom_math::{Frame, Vector};
3183    use ogeom_mesh::{Deflection, triangulate};
3184    use ogeom_topo::explore_unique;
3185
3186    const T: Tolerances = Tolerances::millimetres();
3187
3188    fn fine() -> Deflection {
3189        Deflection {
3190            chord: 0.02,
3191            ..Deflection::default()
3192        }
3193    }
3194
3195    /// One model holding every kind of shape this can build, and the roots that
3196    /// name them.
3197    #[test]
3198    fn a_document_round_trips_with_everything_it_says() {
3199        use ogeom_doc::{Colour, Dimension, GeometricTolerance, MeasureKind};
3200        let mut document = ogeom_doc::Document::new();
3201        let plate = make_box(document.model_mut(), Frame::WORLD, (40.0, 40.0, 5.0), T)
3202            .unwrap()
3203            .shape;
3204        let bolt = make_box(document.model_mut(), Frame::WORLD, (8.0, 8.0, 20.0), T)
3205            .unwrap()
3206            .shape;
3207        let plate_id = document.add_part("plate", plate.clone());
3208        let bolt_id = document.add_part("bolt", bolt.clone());
3209        let assembly = document.add_assembly("bolted plate");
3210        document
3211            .add_instance(assembly, plate_id, Transform::IDENTITY, None)
3212            .unwrap();
3213        document
3214            .add_instance(
3215                assembly,
3216                bolt_id,
3217                Transform::translation(ogeom_math::Vector::new(10.0, 10.0, 5.0)),
3218                Some("bolt one".into()),
3219            )
3220            .unwrap();
3221        document
3222            .set_product_colour(plate_id, Colour::rgb(0.1, 0.8, 0.2))
3223            .unwrap();
3224        let face = explore_unique(document.model(), &bolt, ShapeType::Face).unwrap()[0].clone();
3225        document.set_colour(&face, Colour::rgb(0.9, 0.1, 0.1));
3226        document.set_name(&face, "the mounting face");
3227        document.pmi_mut().dimensions.push(Dimension {
3228            name: "length".into(),
3229            values: vec![20.0],
3230            kind: MeasureKind::Length,
3231            plus: Some(0.1),
3232            minus: Some(-0.1),
3233            features: vec![vec![face.node()]],
3234            location: false,
3235        });
3236        document.pmi_mut().tolerances.push(GeometricTolerance {
3237            kind: "flatness".into(),
3238            name: "Flatness.1".into(),
3239            magnitude: 0.05,
3240            modifiers: vec!["maximum_material_requirement".into()],
3241            datums: vec!["A".into(), "A-B".into()],
3242            datum_system: false,
3243            items: vec![face.node()],
3244        });
3245        document.pmi_mut().datums.push(ogeom_doc::Datum {
3246            label: "A".into(),
3247            items: vec![face.node()],
3248        });
3249
3250        // The attribute layer, all of it: a property of each kind, a
3251        // material with density and colour assigned to the bolt, two
3252        // layers with the face on both and one hidden, and validation
3253        // values recorded against the plate.
3254        document.set_property(
3255            &face,
3256            ogeom_doc::Property {
3257                name: "finish".into(),
3258                value: ogeom_doc::PropertyValue::Text("brushed".into()),
3259            },
3260        );
3261        document.set_property(
3262            &face,
3263            ogeom_doc::Property {
3264                name: "cost".into(),
3265                value: ogeom_doc::PropertyValue::Number(12.5),
3266            },
3267        );
3268        document.set_property(
3269            &bolt,
3270            ogeom_doc::Property {
3271                name: "critical".into(),
3272                value: ogeom_doc::PropertyValue::Flag(true),
3273            },
3274        );
3275        let steel = document.add_material(ogeom_doc::Material {
3276            name: "AISI 304".into(),
3277            density: Some(7900.0),
3278            colour: Some(Colour::rgb(0.7, 0.7, 0.75)),
3279        });
3280        document.assign_material(&bolt, steel);
3281        let outline = document.add_layer("outline");
3282        let hidden = document.add_layer("construction");
3283        document.set_layer_visible(hidden, false);
3284        document.place_on_layer(&face, outline);
3285        document.place_on_layer(&face, hidden);
3286        document.set_validation(
3287            &plate,
3288            ogeom_doc::ValidationProperties {
3289                volume: 8000.0,
3290                area: 2400.0,
3291                centroid: ogeom_math::Point::new(10.0, 10.0, 2.5),
3292            },
3293        );
3294
3295        let text = write_document(&document, WriteOptions::default()).unwrap();
3296        let back = read_document(&text).unwrap();
3297        let again = write_document(&back, WriteOptions::default()).unwrap();
3298        assert_eq!(text, again, "the second write reproduces the first");
3299
3300        // The attribute layer survives whole.
3301        // The face rebinds through its persisted key: the names carry it.
3302        let read_face = back
3303            .names()
3304            .find(|(_, n)| *n == "the mounting face")
3305            .map(|(node, _)| ogeom_topo::Shape::of(node))
3306            .unwrap();
3307        let properties = back.properties_of(&read_face);
3308        assert_eq!(properties.len(), 2);
3309        assert!(
3310            properties.iter().any(|p| p.name == "finish"
3311                && p.value == ogeom_doc::PropertyValue::Text("brushed".into()))
3312        );
3313        assert!(
3314            properties
3315                .iter()
3316                .any(|p| p.name == "cost" && p.value == ogeom_doc::PropertyValue::Number(12.5))
3317        );
3318        assert_eq!(back.materials().len(), 1);
3319        assert_eq!(back.materials()[0].name, "AISI 304");
3320        assert_eq!(back.materials()[0].density, Some(7900.0));
3321        assert_eq!(back.layers().len(), 2);
3322        assert!(back.layers()[0].visible);
3323        assert!(!back.layers()[1].visible);
3324        assert_eq!(back.layers_of(&read_face).len(), 2);
3325        let validation = back
3326            .validations()
3327            .next()
3328            .map(|(_, v)| v)
3329            .expect("the plate's check values survive");
3330        assert!(validation.agrees_with(
3331            &ogeom_doc::ValidationProperties {
3332                volume: 8000.0,
3333                area: 2400.0,
3334                centroid: ogeom_math::Point::new(10.0, 10.0, 2.5),
3335            },
3336            1e-9,
3337        ));
3338
3339        // Structure: same products, same occurrences at the same places.
3340        let names: Vec<&str> = back.products().map(|(_, p)| p.name.as_str()).collect();
3341        assert_eq!(names, ["plate", "bolt", "bolted plate"]);
3342        let root = back.roots()[0];
3343        let mut occurrences = back.occurrences_of(root).unwrap();
3344        occurrences.sort_by(|a, b| a.path.cmp(&b.path));
3345        assert_eq!(occurrences.len(), 2);
3346        assert_eq!(occurrences[0].path, "bolted plate/bolt one");
3347        let world = occurrences[0]
3348            .shape
3349            .transform(back.model().datums())
3350            .unwrap()
3351            .apply(Point::new(0.0, 0.0, 0.0));
3352        assert!(world.is_equal(Point::new(10.0, 10.0, 5.0), T));
3353
3354        // Appearance, names and PMI, intact.
3355        let bolt_face = explore_unique(back.model(), &occurrences[0].shape, ShapeType::Face)
3356            .unwrap()[0]
3357            .clone();
3358        assert_eq!(back.colour_of(&bolt_face), Some(Colour::rgb(0.9, 0.1, 0.1)));
3359        assert_eq!(back.name_of(&bolt_face), Some("the mounting face"));
3360        assert_eq!(back.pmi().dimensions.len(), 1);
3361        assert_eq!(back.pmi().dimensions[0].values, [20.0]);
3362        assert_eq!(back.pmi().tolerances[0].kind, "flatness");
3363        assert_eq!(
3364            back.pmi().tolerances[0].modifiers,
3365            ["maximum_material_requirement"]
3366        );
3367        assert_eq!(back.pmi().tolerances[0].datums, ["A", "A-B"]);
3368        assert_eq!(back.pmi().datums[0].label, "A");
3369        assert_eq!(back.pmi().datums[0].items.len(), 1);
3370    }
3371
3372    #[test]
3373    fn asymmetric_conic_domains_round_trip() {
3374        // The domain a constructor happens to produce is symmetric; the
3375        // format carries whatever the curve actually spans.
3376        let mut model = Model::new();
3377        let hyperbola = ogeom_geom::HyperbolaCurve::over(
3378            Hyperbola::new(Frame::WORLD, 2.0, 1.0, T).unwrap(),
3379            -0.5,
3380            1.75,
3381        )
3382        .unwrap();
3383        let parabola = ogeom_geom::ParabolaCurve::over(
3384            Parabola::new(Frame::WORLD, 1.5, T).unwrap(),
3385            0.25,
3386            3.0,
3387        )
3388        .unwrap();
3389        for curve in [Curve::Hyperbola(hyperbola), Curve::Parabola(parabola)] {
3390            let domain = Curve3d::domain(&curve);
3391            ogeom_algo::make_edge(&mut model, curve, domain, T).unwrap();
3392        }
3393        let text = write(&model, &[], WriteOptions::default()).unwrap();
3394        let (back, _) = read(&text).unwrap();
3395        let again = write(&back, &[], WriteOptions::default()).unwrap();
3396        assert_eq!(text, again, "the second write reproduces the first");
3397        let domains: Vec<(f64, f64)> = back
3398            .geometry()
3399            .curves()
3400            .map(|(_, c)| Curve3d::domain(c))
3401            .collect();
3402        assert!(domains.contains(&(-0.5, 1.75)));
3403        assert!(domains.contains(&(0.25, 3.0)));
3404    }
3405
3406    #[test]
3407    fn derived_geometry_round_trips_byte_stable() {
3408        use ogeom_geom::{
3409            CurveOnSurface, CylinderSurface, OffsetCurve, OffsetSurface, PlanarCurve,
3410        };
3411        use ogeom_math::Cylinder;
3412
3413        let mut model = Model::new();
3414        // An edge on an offset circle: the concentric circle by another
3415        // spelling.
3416        let circle: Curve =
3417            ogeom_geom::CircleCurve::new(ogeom_math::Circle::new(Frame::WORLD, 2.0, T).unwrap())
3418                .into();
3419        let offset = Curve::Offset(Box::new(
3420            OffsetCurve::new(circle, 1.0, ogeom_math::Direction::Z).unwrap(),
3421        ));
3422        let domain = Curve3d::domain(&offset);
3423        ogeom_algo::make_edge(&mut model, offset, domain, T).unwrap();
3424
3425        // An edge on a surface curve: a sloped chart line on a cylinder.
3426        let cylinder = SurfaceGeometry::Cylinder(
3427            CylinderSurface::new(Cylinder::new(Frame::WORLD, 3.0, T).unwrap(), (0.0, 8.0)).unwrap(),
3428        );
3429        let chart_line = PlanarCurve::Line(
3430            ogeom_geom::Line2d::segment(
3431                ogeom_math::Point2::new(0.0, 0.0),
3432                ogeom_math::Point2::new(3.0, 5.0),
3433                T,
3434            )
3435            .unwrap(),
3436        );
3437        let lifted = Curve::OnSurface(Box::new(CurveOnSurface::new(chart_line, cylinder.clone())));
3438        let domain = Curve3d::domain(&lifted);
3439        ogeom_algo::make_edge(&mut model, lifted, domain, T).unwrap();
3440
3441        // A loose offset surface in the store.
3442        model
3443            .geometry_mut()
3444            .add_surface(SurfaceGeometry::Offset(Box::new(
3445                OffsetSurface::new(cylinder, -0.5).unwrap(),
3446            )));
3447
3448        let text = write(&model, &[], WriteOptions::default()).unwrap();
3449        let (back, _) = read(&text).unwrap();
3450        let again = write(&back, &[], WriteOptions::default()).unwrap();
3451        assert_eq!(text, again, "the second write reproduces the first");
3452        assert!(
3453            back.geometry()
3454                .curves()
3455                .any(|(_, c)| matches!(c, Curve::Offset(_)))
3456                && back
3457                    .geometry()
3458                    .curves()
3459                    .any(|(_, c)| matches!(c, Curve::OnSurface(_)))
3460        );
3461    }
3462
3463    #[test]
3464    fn a_version_one_document_still_reads() {
3465        // Version 2 only added records; a version 1 file contains none of
3466        // them, and the promise is that it keeps reading forever.
3467        let mut model = Model::new();
3468        ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
3469        let text = write(&model, &[], WriteOptions::default()).unwrap();
3470        let downgraded = text.replacen("ogeom 2", "ogeom 1", 1);
3471        assert_ne!(text, downgraded, "the header really carried version 2");
3472        let (back, _) = read(&downgraded).unwrap();
3473        assert_eq!(
3474            back.geometry().surfaces().count(),
3475            model.geometry().surfaces().count()
3476        );
3477    }
3478
3479    #[test]
3480    fn a_conical_helix_and_a_periodic_bspline_round_trip_byte_stable() {
3481        let mut model = Model::new();
3482        let helix = ogeom_geom::HelixCurve::conical(
3483            Frame::new(
3484                ogeom_math::Point::new(1.0, 2.0, -0.5),
3485                ogeom_math::Direction::from_coords(0.0, 0.6, 0.8, T).unwrap(),
3486                ogeom_math::Direction::X,
3487                T,
3488            )
3489            .unwrap(),
3490            4.0,
3491            3.0,
3492            1.25,
3493            0.5,
3494            9.0,
3495        )
3496        .unwrap();
3497        let domain = Curve3d::domain(&helix);
3498        ogeom_algo::make_edge(&mut model, Curve::Helix(helix), domain, T).unwrap();
3499
3500        let ring: Vec<ogeom_math::Point> = (0..8)
3501            .map(|i| {
3502                let a = core::f64::consts::TAU * f64::from(i) / 8.0;
3503                ogeom_math::Point::new(a.cos() * 4.0, a.sin() * 4.0, 0.0)
3504            })
3505            .collect();
3506        let loop_curve = ogeom_geom::BSplineCurve::periodic(&ring, 3, T).unwrap();
3507        let domain = Curve3d::domain(&loop_curve);
3508        ogeom_algo::make_edge(&mut model, Curve::BSpline(loop_curve), domain, T).unwrap();
3509
3510        let text = write(&model, &[], WriteOptions::default()).unwrap();
3511        let (back, _) = read(&text).unwrap();
3512        let again = write(&back, &[], WriteOptions::default()).unwrap();
3513        assert_eq!(text, again, "the second write reproduces the first");
3514        assert!(
3515            back.geometry()
3516                .curves()
3517                .any(|(_, c)| matches!(c, Curve::Helix(h) if (h.taper() - 1.25).abs() < 1e-12))
3518        );
3519        assert!(
3520            back.geometry()
3521                .curves()
3522                .any(|(_, c)| matches!(c, Curve::BSpline(b) if b.is_periodic()))
3523        );
3524    }
3525
3526    /// A `.brep` extrusion is given a window reaching both ways from its
3527    /// curve; the reader has to take back the window the writer wrote, not
3528    /// only the `[0, distance]` of a swept profile.
3529    #[test]
3530    fn an_extrusion_over_a_window_below_zero_round_trips() {
3531        let mut model = Model::new();
3532        let line =
3533            Curve::Line(LineCurve::over(Axis::new(Point::ORIGIN, Direction::X), 0.0, 5.0).unwrap());
3534        let extrusion = ExtrusionSurface::over(line, Direction::Z, (-1e9, 1e9)).unwrap();
3535        model.geometry_mut().add_surface(extrusion.into());
3536        let text = write(&model, &[], WriteOptions::default()).unwrap();
3537        let (back, _) = read(&text).unwrap();
3538        assert_eq!(write(&back, &[], WriteOptions::default()).unwrap(), text);
3539        let window = back
3540            .geometry()
3541            .surfaces()
3542            .find_map(|(_, s)| match s {
3543                SurfaceGeometry::Extrusion(e) => Some(e.domain().1),
3544                _ => None,
3545            })
3546            .expect("the extrusion survives");
3547        assert_eq!(window, (-1e9, 1e9));
3548    }
3549
3550    /// The window still has to be one: empty or backwards is refused.
3551    #[test]
3552    fn an_extrusion_over_an_empty_window_is_refused() {
3553        let mut model = Model::new();
3554        let line =
3555            Curve::Line(LineCurve::over(Axis::new(Point::ORIGIN, Direction::X), 0.0, 5.0).unwrap());
3556        let extrusion = ExtrusionSurface::new(line, Direction::Z, 3.0).unwrap();
3557        model.geometry_mut().add_surface(extrusion.into());
3558        let text = write(&model, &[], WriteOptions::default()).unwrap();
3559        assert!(read(&text).is_ok());
3560        let window = "1.0 0.0 3.0 line";
3561        assert!(text.contains(window), "{text}");
3562        for bad in ["1.0 3.0 3.0 line", "1.0 3.0 0.0 line", "1.0 0.0 inf line"] {
3563            assert!(
3564                read(&text.replacen(window, bad, 1)).is_err(),
3565                "`{bad}` read"
3566            );
3567        }
3568    }
3569
3570    #[test]
3571    fn a_helix_edge_round_trips_byte_stable() {
3572        let mut model = Model::new();
3573        let helix = ogeom_geom::HelixCurve::over(
3574            Frame::new(
3575                ogeom_math::Point::new(1.0, 2.0, -0.5),
3576                ogeom_math::Direction::from_coords(0.0, 0.6, 0.8, T).unwrap(),
3577                ogeom_math::Direction::X,
3578                T,
3579            )
3580            .unwrap(),
3581            4.0,
3582            -1.5,
3583            0.5,
3584            9.0,
3585        )
3586        .unwrap();
3587        let domain = Curve3d::domain(&helix);
3588        ogeom_algo::make_edge(&mut model, Curve::Helix(helix), domain, T).unwrap();
3589        let text = write(&model, &[], WriteOptions::default()).unwrap();
3590        let (back, _) = read(&text).unwrap();
3591        let again = write(&back, &[], WriteOptions::default()).unwrap();
3592        assert_eq!(text, again, "the second write reproduces the first");
3593        let restored = back
3594            .geometry()
3595            .curves()
3596            .find_map(|(_, c)| match c {
3597                Curve::Helix(h) => Some(*h),
3598                _ => None,
3599            })
3600            .expect("the helix survives");
3601        assert_eq!(restored.radius(), 4.0);
3602        assert_eq!(restored.pitch(), -1.5);
3603        assert_eq!(Curve3d::domain(&restored), (0.5, 9.0));
3604    }
3605
3606    fn everything() -> (Model, Vec<Shape>) {
3607        let mut model = Model::new();
3608        let mut roots = vec![
3609            make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
3610                .unwrap()
3611                .shape,
3612            make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T)
3613                .unwrap()
3614                .shape,
3615            make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap().shape,
3616            make_cone(&mut model, Frame::WORLD, 3.0, 1.0, 4.0, T)
3617                .unwrap()
3618                .shape,
3619            make_cone(&mut model, Frame::WORLD, 3.0, 0.0, 4.0, T)
3620                .unwrap()
3621                .shape,
3622            make_torus(&mut model, Frame::WORLD, 5.0, 2.0, T)
3623                .unwrap()
3624                .shape,
3625            make_wedge(&mut model, Frame::WORLD, (4.0, 4.0, 6.0), (2.0, 2.0), T)
3626                .unwrap()
3627                .shape,
3628        ];
3629
3630        // A prism, so an extrusion surface and a placement datum are in there.
3631        let face = explore_unique(&model, &roots[0], ShapeType::Face).unwrap()[0].clone();
3632        roots.push(
3633            make_prism(&mut model, &face, Vector::new(0.0, 0.0, 2.0), T)
3634                .unwrap()
3635                .shape,
3636        );
3637        // And a revolution, so a surface of revolution and a seam are too. The
3638        // profile has to lie in a plane the axis runs through and stay clear of
3639        // it, or the swept solid would pass through itself, so it is the box's
3640        // `-y` face, spanning x and z, turned about a z axis five units away.
3641        let side = explore_unique(&model, &roots[0], ShapeType::Face)
3642            .unwrap()
3643            .into_iter()
3644            .find(|f| {
3645                model
3646                    .provenance_of(f)
3647                    .and_then(ogeom_core::Provenance::role)
3648                    == Some(ogeom_algo::primitive::roles::FACE_MIN_Y)
3649            })
3650            .expect("the box has a -y face");
3651        let axis = ogeom_math::Axis::new(
3652            ogeom_math::Point::new(-5.0, 0.0, 0.0),
3653            ogeom_math::Direction::Z,
3654        );
3655        roots.push(
3656            make_revolution(&mut model, &side, axis, 1.0, T)
3657                .unwrap()
3658                .shape,
3659        );
3660        (model, roots)
3661    }
3662
3663    #[test]
3664    fn a_file_that_is_not_one_is_refused() {
3665        assert!(read("").is_err());
3666        assert!(read("something else 1").is_err());
3667        assert!(read(&format!("{MAGIC} 99")).is_err());
3668        assert!(read(&format!("{MAGIC} 1\nnonsense\n")).is_err());
3669    }
3670
3671    #[test]
3672    fn writing_what_was_read_gives_the_same_bytes() {
3673        // The property the whole format exists for. If a round trip through it
3674        // is not the identity, `diff` says exactly where, which is why this is
3675        // text and not a binary blob.
3676        let (model, roots) = everything();
3677        let first = write(&model, &roots, WriteOptions::default()).unwrap();
3678        let (restored, restored_roots) = read(&first).unwrap();
3679        let second = write(&restored, &restored_roots, WriteOptions::default()).unwrap();
3680
3681        if first != second {
3682            let mismatch = first
3683                .lines()
3684                .zip(second.lines())
3685                .enumerate()
3686                .find(|(_, (a, b))| a != b);
3687            panic!("the round trip changed the document: {mismatch:?}");
3688        }
3689        assert_eq!(restored_roots.len(), roots.len());
3690    }
3691
3692    #[test]
3693    fn the_restored_model_is_the_same_model() {
3694        // Byte equality of the file is necessary and not sufficient: what
3695        // matters is that the shapes still answer the same way. Same counts,
3696        // same validity, same measurements, same handles.
3697        let (model, roots) = everything();
3698        let text = write(&model, &roots, WriteOptions::default()).unwrap();
3699        let (restored, restored_roots) = read(&text).unwrap();
3700
3701        assert_eq!(restored.node_count(), model.node_count());
3702        assert_eq!(restored.geometry().counts(), model.geometry().counts());
3703        assert_eq!(restored.current_operation(), model.current_operation());
3704
3705        for (before, after) in roots.iter().zip(&restored_roots) {
3706            // The *slot* came back, not merely something like it: same index,
3707            // same generation. Not the same handle, though: a restored
3708            // document is a new set of arenas and its handles say so, which is
3709            // what stops one document's shape from resolving against another.
3710            assert_eq!(before.node().index(), after.node().index());
3711            assert_eq!(before.node().generation(), after.node().generation());
3712            assert_ne!(
3713                before.node().scope(),
3714                after.node().scope(),
3715                "a restored document should not answer to the original's handles"
3716            );
3717            assert!(model.node(after).is_none(), "and not the other way round");
3718            // A reference kept across a save is re-found by its *identity*,
3719            // not by its handle. `bind` deliberately refuses a shape from
3720            // another document (relabelling one would hand back something that
3721            // resolves and answers about a different entity), so the way
3722            // through is the identity (`docs/DATA_MODEL.md` §8).
3723            assert!(
3724                restored.bind(before).is_err(),
3725                "a foreign handle should not be re-homed"
3726            );
3727
3728            for kind in [
3729                ShapeType::Face,
3730                ShapeType::Edge,
3731                ShapeType::Vertex,
3732                ShapeType::Shell,
3733            ] {
3734                assert_eq!(
3735                    explore_unique(&restored, after, kind).unwrap().len(),
3736                    explore_unique(&model, before, kind).unwrap().len(),
3737                    "{kind:?} count changed"
3738                );
3739            }
3740
3741            assert!(
3742                check(&restored, after, T).unwrap().is_valid(),
3743                "restored shape is invalid: {}",
3744                check(&restored, after, T).unwrap()
3745            );
3746            assert!(
3747                check_tessellation(&restored, after, fine(), T)
3748                    .unwrap()
3749                    .is_valid(),
3750                "restored shape's mesh came apart"
3751            );
3752
3753            let before_mesh = triangulate(&model, before, fine(), T).unwrap();
3754            let after_mesh = triangulate(&restored, after, fine(), T).unwrap();
3755            assert_eq!(after_mesh.triangle_count(), before_mesh.triangle_count());
3756            assert_relative_eq!(
3757                after_mesh.volume(),
3758                before_mesh.volume(),
3759                max_relative = 0.0
3760            );
3761        }
3762    }
3763
3764    #[test]
3765    fn provenance_and_identity_survive() {
3766        // The reason reading does not go through the builders. A rebuild
3767        // through them would mint fresh identities, and every reference
3768        // recorded against the old ones ("the top face of that box") would
3769        // resolve to nothing or, worse, to something else.
3770        let (model, roots) = everything();
3771        let text = write(&model, &roots, WriteOptions::default()).unwrap();
3772        let (restored, restored_roots) = read(&text).unwrap();
3773
3774        for (before, after) in roots.iter().zip(&restored_roots) {
3775            let faces = explore_unique(&model, before, ShapeType::Face).unwrap();
3776            let restored_faces = explore_unique(&restored, after, ShapeType::Face).unwrap();
3777            assert_eq!(faces.len(), restored_faces.len());
3778            for (a, b) in faces.iter().zip(&restored_faces) {
3779                assert_eq!(
3780                    restored.identity_of(b),
3781                    model.identity_of(a),
3782                    "an identity was renumbered"
3783                );
3784                // And the identity is enough to find the entity again in the
3785                // reloaded document, which is what makes a saved reference
3786                // survive at all.
3787                if let Some(id) = model.identity_of(a) {
3788                    let found = restored.shape_of(id).expect("the entity is still there");
3789                    assert!(found.is_partner(b), "identity found the wrong node");
3790                }
3791                assert_eq!(
3792                    restored.provenance_of(b),
3793                    model.provenance_of(a),
3794                    "a provenance record changed"
3795                );
3796                assert_eq!(restored.roots_of(b), model.roots_of(a));
3797            }
3798        }
3799        assert_eq!(
3800            restored.provenance().len(),
3801            model.provenance().len(),
3802            "the table changed length"
3803        );
3804    }
3805
3806    #[test]
3807    fn everything_reads_into_a_live_model_and_still_answers_the_same() {
3808        // The same claims `the_restored_model_is_the_same_model` makes, with
3809        // the document landing in a model that already has things in it.
3810        let (model, roots) = everything();
3811        let text = write(&model, &roots, WriteOptions::default()).unwrap();
3812
3813        let mut target = Model::new();
3814        let resident = make_box(&mut target, Frame::WORLD, (7.0, 7.0, 7.0), T)
3815            .unwrap()
3816            .shape;
3817        let absorbed = read_into(&mut target, &text).unwrap();
3818        assert_eq!(absorbed.shapes.len(), roots.len());
3819
3820        for (before, after) in roots.iter().zip(&absorbed.shapes) {
3821            for kind in [
3822                ShapeType::Face,
3823                ShapeType::Edge,
3824                ShapeType::Vertex,
3825                ShapeType::Shell,
3826            ] {
3827                assert_eq!(
3828                    explore_unique(&target, after, kind).unwrap().len(),
3829                    explore_unique(&model, before, kind).unwrap().len(),
3830                    "{kind:?} count changed"
3831                );
3832            }
3833            assert!(
3834                check(&target, after, T).unwrap().is_valid(),
3835                "absorbed shape is invalid: {}",
3836                check(&target, after, T).unwrap()
3837            );
3838            let before_mesh = triangulate(&model, before, fine(), T).unwrap();
3839            let after_mesh = triangulate(&target, after, fine(), T).unwrap();
3840            assert_eq!(after_mesh.triangle_count(), before_mesh.triangle_count());
3841            assert_relative_eq!(
3842                after_mesh.volume(),
3843                before_mesh.volume(),
3844                max_relative = 0.0
3845            );
3846        }
3847
3848        // What lived here before is untouched and still measures.
3849        assert!(check(&target, &resident, T).unwrap().is_valid());
3850        assert_relative_eq!(
3851            triangulate(&target, &resident, fine(), T).unwrap().volume(),
3852            343.0,
3853            max_relative = 1e-9
3854        );
3855    }
3856
3857    #[test]
3858    fn identities_read_into_a_live_model_survive_under_an_offset() {
3859        // `provenance_and_identity_survive`'s sibling: the ids are not the
3860        // file's own (the target had already issued some), but the remap
3861        // table says exactly where each one landed, and everything found
3862        // through it answers the same.
3863        let (model, roots) = everything();
3864        let text = write(&model, &roots, WriteOptions::default()).unwrap();
3865
3866        let mut target = Model::new();
3867        make_box(&mut target, Frame::WORLD, (7.0, 7.0, 7.0), T).unwrap();
3868        let issued_before = target.provenance().len() as u64;
3869        assert!(issued_before > 0);
3870        let absorbed = read_into(&mut target, &text).unwrap();
3871
3872        for (before, after) in roots.iter().zip(&absorbed.shapes) {
3873            let faces = explore_unique(&model, before, ShapeType::Face).unwrap();
3874            let absorbed_faces = explore_unique(&target, after, ShapeType::Face).unwrap();
3875            assert_eq!(faces.len(), absorbed_faces.len());
3876            for (a, b) in faces.iter().zip(&absorbed_faces) {
3877                let old = model.identity_of(a).expect("faces carry identities");
3878                let new = absorbed.entities[&old];
3879                assert_eq!(new.get(), old.get() + issued_before, "not a plain shift");
3880                assert_eq!(target.identity_of(b), Some(new));
3881                let found = target.shape_of(new).expect("the entity is findable");
3882                assert!(found.is_partner(b), "identity found the wrong node");
3883                assert_eq!(
3884                    target.provenance_of(b).and_then(Provenance::role),
3885                    model.provenance_of(a).and_then(Provenance::role),
3886                    "a role changed in the shift"
3887                );
3888            }
3889        }
3890        assert_eq!(
3891            target.provenance().len() as u64,
3892            issued_before + model.provenance().len() as u64
3893        );
3894    }
3895
3896    #[test]
3897    fn a_document_in_other_units_does_not_read_into_a_millimetre_model() {
3898        let mut metric = Model::with_tolerances(Tolerances::metres());
3899        let solid = make_box(
3900            &mut metric,
3901            Frame::WORLD,
3902            (1.0, 1.0, 1.0),
3903            Tolerances::metres(),
3904        )
3905        .unwrap()
3906        .shape;
3907        let text = write(
3908            &metric,
3909            std::slice::from_ref(&solid),
3910            WriteOptions::default(),
3911        )
3912        .unwrap();
3913
3914        let mut target = Model::new();
3915        make_box(&mut target, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
3916        let nodes_before = target.node_count();
3917        assert!(
3918            read_into(&mut target, &text).is_err(),
3919            "a metre document should not land in a millimetre model"
3920        );
3921        assert_eq!(
3922            target.node_count(),
3923            nodes_before,
3924            "a refused read should leave the model alone"
3925        );
3926    }
3927
3928    #[test]
3929    fn reading_the_same_document_into_a_model_twice_gives_independent_copies() {
3930        let mut model = Model::new();
3931        let solid = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T)
3932            .unwrap()
3933            .shape;
3934        let text = write(
3935            &model,
3936            std::slice::from_ref(&solid),
3937            WriteOptions::default(),
3938        )
3939        .unwrap();
3940
3941        let mut target = Model::new();
3942        let first = read_into(&mut target, &text).unwrap();
3943        let second = read_into(&mut target, &text).unwrap();
3944
3945        let a = &first.shapes[0];
3946        let b = &second.shapes[0];
3947        assert_ne!(
3948            a.node().index(),
3949            b.node().index(),
3950            "the copies share a node"
3951        );
3952        // Containers carry no identity; a face of each copy does, and they
3953        // must differ: the second read is a second body, not an alias.
3954        let face_a = explore_unique(&target, a, ShapeType::Face).unwrap()[0].clone();
3955        let face_b = explore_unique(&target, b, ShapeType::Face).unwrap()[0].clone();
3956        assert_ne!(
3957            target.identity_of(&face_a),
3958            target.identity_of(&face_b),
3959            "the copies share an identity"
3960        );
3961        assert_relative_eq!(
3962            triangulate(&target, a, fine(), T).unwrap().volume(),
3963            triangulate(&target, b, fine(), T).unwrap().volume(),
3964            max_relative = 0.0
3965        );
3966    }
3967
3968    #[test]
3969    fn read_into_reads_the_same_version_read_does() {
3970        // `read_into` reads the same grammar under the same VERSION, so both
3971        // readers refuse the same unknown one.
3972        let future = format!("{MAGIC} 99\nunits 1.0\n");
3973        assert!(read(&future).is_err());
3974        let mut target = Model::new();
3975        assert!(read_into(&mut target, &future).is_err());
3976    }
3977
3978    #[test]
3979    fn a_document_with_meshes_reads_into_a_live_model() {
3980        let mut model = Model::new();
3981        let solid = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T)
3982            .unwrap()
3983            .shape;
3984        ogeom_mesh::tessellate(&mut model, &solid, fine(), T).unwrap();
3985        assert!(model.geometry().triangulation_count() > 0);
3986        let text = write(
3987            &model,
3988            std::slice::from_ref(&solid),
3989            WriteOptions::default(),
3990        )
3991        .unwrap();
3992
3993        let mut target = Model::new();
3994        make_box(&mut target, Frame::WORLD, (7.0, 7.0, 7.0), T).unwrap();
3995        let absorbed = read_into(&mut target, &text).unwrap();
3996        assert_eq!(
3997            target.geometry().triangulation_count(),
3998            model.geometry().triangulation_count()
3999        );
4000        // The absorbed faces' cached-mesh handles resolve where they landed.
4001        for face in explore_unique(&target, &absorbed.shapes[0], ShapeType::Face).unwrap() {
4002            let node = target.node(&face).unwrap();
4003            let mesh = node
4004                .data()
4005                .as_face()
4006                .unwrap()
4007                .triangulation
4008                .expect("the cache travelled");
4009            assert!(target.geometry().triangulation(mesh).is_some());
4010        }
4011    }
4012
4013    #[test]
4014    fn tolerances_survive_entity_by_entity() {
4015        // Per-entity and they only grow (`docs/DATA_MODEL.md` §5). A format
4016        // that wrote one number for the document would quietly tighten every
4017        // edge a repair had widened.
4018        let mut model = Model::new();
4019        let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
4020            .unwrap()
4021            .shape;
4022        let edge = explore_unique(&model, &solid, ShapeType::Edge).unwrap()[0].clone();
4023        model.widen(&edge, Tolerance::new(1e-3).unwrap()).unwrap();
4024
4025        let text = write(
4026            &model,
4027            std::slice::from_ref(&solid),
4028            WriteOptions::default(),
4029        )
4030        .unwrap();
4031        let (restored, roots) = read(&text).unwrap();
4032        for kind in [ShapeType::Vertex, ShapeType::Edge, ShapeType::Face] {
4033            let before = explore_unique(&model, &solid, kind).unwrap();
4034            let after = explore_unique(&restored, &roots[0], kind).unwrap();
4035            for (a, b) in before.iter().zip(&after) {
4036                assert_eq!(
4037                    restored.tolerance_of(b).unwrap().map(Tolerance::get),
4038                    model.tolerance_of(a).unwrap().map(Tolerance::get),
4039                    "a {kind:?} tolerance changed"
4040                );
4041            }
4042        }
4043        assert!(
4044            restored
4045                .tolerance_of(&roots[0])
4046                .into_iter()
4047                .flatten()
4048                .count()
4049                <= 1
4050        );
4051    }
4052
4053    #[test]
4054    fn a_cached_mesh_survives_and_omitting_it_says_so() {
4055        let mut model = Model::new();
4056        let solid = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T)
4057            .unwrap()
4058            .shape;
4059        ogeom_mesh::tessellate(&mut model, &solid, fine(), T).unwrap();
4060        assert!(model.geometry().triangulation_count() > 0);
4061
4062        let text = write(
4063            &model,
4064            std::slice::from_ref(&solid),
4065            WriteOptions::default(),
4066        )
4067        .unwrap();
4068        let (restored, _) = read(&text).unwrap();
4069        assert_eq!(
4070            restored.geometry().triangulation_count(),
4071            model.geometry().triangulation_count()
4072        );
4073        for (before, after) in model
4074            .geometry()
4075            .triangulations()
4076            .zip(restored.geometry().triangulations())
4077        {
4078            assert_eq!(before.1, after.1, "a cached mesh changed");
4079        }
4080
4081        // Omitted on request, and the file says so rather than leaving it to be
4082        // inferred from an absence.
4083        let without = write(
4084            &model,
4085            std::slice::from_ref(&solid),
4086            WriteOptions {
4087                triangulations: false,
4088            },
4089        )
4090        .unwrap();
4091        assert!(without.contains("triangulation(s) omitted by request"));
4092        assert!(
4093            without.len() < text.len(),
4094            "omitting the mesh saved nothing"
4095        );
4096        let (bare, bare_roots) = read(&without).unwrap();
4097        assert_eq!(bare.geometry().triangulation_count(), 0);
4098        // And the shape is still a shape: the mesh was a cache, not the model.
4099        assert!(check(&bare, &bare_roots[0], T).unwrap().is_valid());
4100    }
4101
4102    #[test]
4103    fn a_document_naming_a_handle_that_is_not_there_is_refused() {
4104        // A corrupt file is an error, not a model that answers about the wrong
4105        // entity.
4106        let mut model = Model::new();
4107        let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
4108            .unwrap()
4109            .shape;
4110        let text = write(
4111            &model,
4112            std::slice::from_ref(&solid),
4113            WriteOptions::default(),
4114        )
4115        .unwrap();
4116
4117        let broken = text.replace("curve3d 0:0", "curve3d 999:0");
4118        assert_ne!(broken, text, "the substitution found nothing to do");
4119        assert!(read(&broken).is_err(), "a dangling curve was accepted");
4120
4121        // And an identity nobody issued.
4122        let broken = format!("{text}identity 0:0 99999\n");
4123        assert!(read(&broken).is_err(), "a dangling identity was accepted");
4124
4125        // And a node bound to an identity that is fine, but to a node that is
4126        // not there.
4127        let broken = format!("{text}identity 9999:0 1\n");
4128        assert!(
4129            read(&broken).is_err(),
4130            "a dangling node binding was accepted"
4131        );
4132    }
4133
4134    #[test]
4135    fn records_out_of_arena_order_are_refused() {
4136        // The order *is* the numbering: a reader replays inserts to reproduce
4137        // the handles. A gap would shift every later reference by one and
4138        // nothing would say so.
4139        let mut model = Model::new();
4140        let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
4141            .unwrap()
4142            .shape;
4143        let text = write(
4144            &model,
4145            std::slice::from_ref(&solid),
4146            WriteOptions::default(),
4147        )
4148        .unwrap();
4149        let shuffled: String = {
4150            let mut lines: Vec<&str> = text.lines().collect();
4151            let first = lines
4152                .iter()
4153                .position(|l| l.starts_with("curve 0:0"))
4154                .expect("a curve");
4155            lines.swap(first, first + 1);
4156            lines.join("\n")
4157        };
4158        assert!(
4159            read(&shuffled).is_err(),
4160            "an out-of-order curve was accepted"
4161        );
4162    }
4163
4164    #[test]
4165    fn comments_and_blank_lines_are_ignored() {
4166        let mut model = Model::new();
4167        let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
4168            .unwrap()
4169            .shape;
4170        let text = write(
4171            &model,
4172            std::slice::from_ref(&solid),
4173            WriteOptions::default(),
4174        )
4175        .unwrap();
4176        let annotated = format!("# a note\n\n{text}\n\n# and another\n");
4177        let (restored, roots) = read(&annotated).unwrap();
4178        assert_eq!(restored.node_count(), model.node_count());
4179        assert_eq!(roots.len(), 1);
4180    }
4181
4182    #[test]
4183    fn a_root_that_is_not_in_the_model_is_refused() {
4184        let mut model = Model::new();
4185        make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
4186        let beyond = Shape::of(Key::from_parts(9999, 0));
4187        assert!(
4188            write(
4189                &model,
4190                std::slice::from_ref(&beyond),
4191                WriteOptions::default()
4192            )
4193            .is_err()
4194        );
4195
4196        // A handle from a different model is caught by the same check: arena
4197        // keys carry the identifier of the arena that issued them, so a foreign
4198        // one resolves to nothing here rather than to whatever node sits at
4199        // that index.
4200        let mut elsewhere = Model::new();
4201        let foreign = ogeom_algo::make_sphere(&mut elsewhere, Frame::WORLD, 1.0, T)
4202            .unwrap()
4203            .shape;
4204        assert!(
4205            write(
4206                &model,
4207                std::slice::from_ref(&foreign),
4208                WriteOptions::default()
4209            )
4210            .is_err()
4211        );
4212    }
4213
4214    #[test]
4215    fn every_float_comes_back_bit_for_bit() {
4216        // The format is exact, not merely close. A text format that printed six
4217        // decimals would give a round trip that always disagrees a little, and
4218        // then a real disagreement has nothing to stand out against.
4219        let mut model = Model::new();
4220        let awkward = [
4221            0.1_f64,
4222            -0.0,
4223            1e-300,
4224            1.0 / 3.0,
4225            f64::MAX / 4.0,
4226            f64::MIN_POSITIVE,
4227            std::f64::consts::PI,
4228        ];
4229        let mut points = Vec::new();
4230        for value in awkward {
4231            points.push(model.add_point(ogeom_math::Point::new(value, -value, value * 2.0)));
4232        }
4233        let text = write(&model, &points, WriteOptions::default()).unwrap();
4234        let (restored, roots) = read(&text).unwrap();
4235        for (before, after) in points.iter().zip(&roots) {
4236            let a = model
4237                .node(before)
4238                .unwrap()
4239                .data()
4240                .as_vertex()
4241                .unwrap()
4242                .point;
4243            let b = restored
4244                .node(after)
4245                .unwrap()
4246                .data()
4247                .as_vertex()
4248                .unwrap()
4249                .point;
4250            assert_eq!(a.x.to_bits(), b.x.to_bits());
4251            assert_eq!(a.y.to_bits(), b.y.to_bits());
4252            assert_eq!(a.z.to_bits(), b.z.to_bits());
4253        }
4254    }
4255}
4256
4257#[cfg(test)]
4258#[allow(clippy::unwrap_used, clippy::expect_used)]
4259mod unit_tests {
4260    use super::*;
4261    use ogeom_algo::make_box;
4262    use ogeom_math::Frame;
4263
4264    #[test]
4265    fn a_documents_unit_scale_survives_the_round_trip() {
4266        // Without this the file reads back correctly and is *validated* against
4267        // whatever the reader assumed, so geometry legitimate at one scale
4268        // could be refused at another with nothing to say why.
4269        for tolerances in [
4270            Tolerances::millimetres(),
4271            Tolerances::metres(),
4272            Tolerances::inches(),
4273        ] {
4274            let mut model = Model::with_tolerances(tolerances);
4275            let solid = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), tolerances)
4276                .unwrap()
4277                .shape;
4278            let text = write(
4279                &model,
4280                std::slice::from_ref(&solid),
4281                WriteOptions::default(),
4282            )
4283            .unwrap();
4284
4285            let (restored, restored_roots) = read(&text).unwrap();
4286            assert_eq!(
4287                restored.tolerances().scale(),
4288                tolerances.scale(),
4289                "the scale changed"
4290            );
4291            assert_eq!(
4292                restored.tolerances().confusion(),
4293                tolerances.confusion(),
4294                "and so did what counts as the same point"
4295            );
4296            // And writing it again gives the same bytes, scale included. The
4297            // roots have to be *this* model's; a second `read` makes a third
4298            // document, whose handles this one rightly will not accept.
4299            let again = write(&restored, &restored_roots, WriteOptions::default()).unwrap();
4300            assert_eq!(text, again);
4301        }
4302    }
4303
4304    #[test]
4305    fn a_document_that_does_not_say_its_units_is_refused() {
4306        let mut model = Model::new();
4307        let solid = make_box(
4308            &mut model,
4309            Frame::WORLD,
4310            (1.0, 1.0, 1.0),
4311            Tolerances::millimetres(),
4312        )
4313        .unwrap()
4314        .shape;
4315        let text = write(
4316            &model,
4317            std::slice::from_ref(&solid),
4318            WriteOptions::default(),
4319        )
4320        .unwrap();
4321
4322        let without: String = text
4323            .lines()
4324            .filter(|line| !line.starts_with("units "))
4325            .collect::<Vec<_>>()
4326            .join("\n");
4327        let err = read(&without).unwrap_err();
4328        assert!(
4329            err.to_string().contains("units"),
4330            "unexpected message: {err}"
4331        );
4332
4333        // And a scale that is not a scale.
4334        let broken = text.replace("units ", "units -");
4335        assert!(read(&broken).is_err());
4336    }
4337}