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