Skip to main content

ogeom_io/step/
read.rs

1//! From parsed exchange structure to a living model.
2//!
3//! The reader walks every `MANIFOLD_SOLID_BREP` (`BREP_WITH_VOIDS` is one,
4//! by subtype) and every `SHELL_BASED_SURFACE_MODEL`, and rebuilds them
5//! bottom-up:
6//! points, placements, curves and surfaces into geometry; vertices, edges,
7//! loops, faces and shells into topology, shared exactly as the file shares
8//! them: a vertex referenced by eight edges is one vertex here too, which is
9//! what lets a closed shell close. Edge ranges are re-derived on this
10//! kernel's own parameterizations from the vertex geometry, because STEP's
11//! parameterizations are its own business and carrying them over blind is
12//! how off-by-a-period bugs are born. Geometric sets in geometrically
13//! bounded representations are construction geometry: named items with no
14//! topology, kept on the document under their product.
15//!
16//! What the reader does not understand it *counts*: every instance never
17//! visited lands in the report's skipped table by keyword, and every
18//! compromise (a face without a pcurve, a shell that does not close) is a
19//! warning with the instance number in it. An import that succeeded with
20//! three warnings is a different thing from one that succeeded, and the
21//! report is what keeps the difference visible.
22
23use super::parse::{Arg, Instance, Instances};
24use ogeom_algo::{
25    make_edge_between, make_face_on, make_shell, make_solid, make_vertex, make_wire,
26    project_on_curve,
27};
28use ogeom_core::{FastMap, FastSet};
29use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
30use ogeom_doc::ConstructionGeometry;
31use ogeom_geom::Curve2d as _;
32use ogeom_geom::Curve3d as _;
33use ogeom_geom::Surface as _;
34use ogeom_geom::Transformable as _;
35use ogeom_geom::{
36    BSplineCurve, CircleCurve, ConeSurface, Curve, CylinderSurface, EllipseCurve, ExtrusionSurface,
37    HyperbolaCurve, LineCurve, ParabolaCurve, PlanarCurve, PlaneSurface, SphereSurface,
38    SurfaceGeometry, TorusSurface,
39};
40use ogeom_math::{
41    Axis, Blend as _, Circle, Cone, Cylinder, Direction, Ellipse, Frame, Hyperbola, KnotVector,
42    Matrix3, Parabola, Plane, Point, Sphere, Torus, Transform, Vector, Weighted,
43};
44use ogeom_topo::{Location, Model, Shape};
45use std::collections::BTreeMap;
46
47/// One use a bound makes of an edge: the edge as the loop walks it, the edge
48/// as it was built, the file's id for it, and the curve and range it carries.
49type BoundUse = (Shape, Shape, u64, Curve, (f64, f64));
50
51/// How a point of space reads on a surface's unbounded directions, `None`
52/// where the direction has none to read.
53type ChartReading = dyn Fn(Point) -> (Option<f64>, Option<f64>);
54
55/// How far a plane or a cylinder read from a file extends past what anything
56/// in the file uses. A face's trim is its wires; the surface's domain is only
57/// the parameter window, and this one is generous without being unbounded.
58const SURFACE_EXTENT: f64 = 1e5;
59
60/// What an import brought in, and what it left behind.
61#[derive(Debug, Default)]
62pub struct StepReport {
63    /// Millimetres per file unit, as the file's own unit section states it.
64    pub scale_mm: f64,
65    /// Instance keywords the reader never visited, with counts. Presentation,
66    /// product structure and annotation live here by design; geometry landing
67    /// here is a gap worth reading about.
68    pub skipped: BTreeMap<String, usize>,
69    /// Everything that imported less than perfectly, one line each.
70    pub warnings: Vec<String>,
71    /// The warning flood, counted: one entry per *kind* of imperfection,
72    /// with how often it happened, the worst measured value where the kind
73    /// measures one, and one exemplar entity id. A file with hundreds of
74    /// warnings summarises to a handful of lines a consumer can show;
75    /// `warnings` keeps the full prose. Sorted by count, largest first.
76    pub summary: Vec<WarningSummary>,
77    /// Faces that read without a complete trim: an edge's boundary sat too
78    /// far from the surface for any honest pcurve (beyond the one-millimetre
79    /// healing cap), so the face will refuse to triangulate. Deduplicated,
80    /// in file order: the structured form of the warnings that name them,
81    /// carrying the face itself so the instructed follow-up needs no search.
82    /// `check` reports the same faces as broken from the model side. A
83    /// refused id whose face never finished building has nothing to act on
84    /// and stays in the warnings alone.
85    pub untrimmed_faces: Vec<UntrimmedFace>,
86    /// Edges on faces whose pcurve the file states and the reader kept: it
87    /// follows the edge's curve, once lifted, as closely as a derived one.
88    /// Each face's use of an edge counts once.
89    pub pcurves_kept: usize,
90    /// Edges on faces whose pcurve the file states but the reader derived
91    /// instead, the file's standing farther from the edge's curve.
92    pub pcurves_derived: usize,
93}
94
95/// One kind of imperfect import, counted rather than repeated.
96#[derive(Debug, Clone)]
97pub struct WarningSummary {
98    /// The kind, stable across runs: `"vertex-miss"`, `"boundary-slop"`,
99    /// `"fit-short"`, `"untrimmed"`, `"pole-crossed"`.
100    pub kind: &'static str,
101    /// How many times it happened.
102    pub count: usize,
103    /// The worst measured value among them: a distance, for every kind
104    /// that measures one; zero where none applies.
105    pub worst: f64,
106    /// One entity id to look at first.
107    pub exemplar: u64,
108}
109
110/// A face the reader could not trim, with the shape to act on.
111#[derive(Debug, Clone)]
112pub struct UntrimmedFace {
113    /// The file's id for the face: the same id the warnings name.
114    pub entity: u64,
115    /// The face as built; hand it to `ogeom_heal::fix_face_pcurves` with
116    /// the cap the situation deserves.
117    pub face: Shape,
118}
119
120/// A read exchange file: the model, the bodies found, and the report.
121#[derive(Debug)]
122pub struct StepImport {
123    /// The document everything was built into: the model, plus the file's
124    /// product structure, names, colours and construction geometry.
125    pub document: ogeom_doc::Document,
126    /// One shape per `MANIFOLD_SOLID_BREP` (`BREP_WITH_VOIDS` included,
127    /// its cavities among its shells), in file order.
128    pub solids: Vec<Shape>,
129    /// One shape per `SHELL_BASED_SURFACE_MODEL`, in file order: its shell,
130    /// or a compound of its shells when it names several.
131    ///
132    /// A surface body is what a modeller exports for a part built from faces
133    /// rather than from a solid. It stays a shell here even when it happens
134    /// to close: the file did not call it a solid, and the document carries
135    /// it under its product exactly as the file placed it.
136    pub shells: Vec<Shape>,
137    /// Every face read, with its `ADVANCED_FACE` or `FACE_SURFACE` id, in id
138    /// order: the face as the solids and shells above hold it, so that
139    /// [`Shape::is_partner`] finds it in them. The id names the face across
140    /// reads of the same file, as no handle does.
141    pub faces: Vec<(u64, Shape)>,
142    /// What happened along the way.
143    pub report: StepReport,
144}
145
146/// Read a STEP exchange file's B-rep content.
147///
148/// The geometric sets and axes of its geometrically bounded wireframe and
149/// surface representations read as construction geometry, named, under the
150/// product each representation belongs to.
151///
152/// # Errors
153///
154/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the file does
155/// not parse, contains no solid, sheet or geometric set, or a solid's
156/// structure is broken in a way topology cannot represent. Faces the reader cannot complete become
157/// warnings, not errors; the report says exactly what was compromised.
158pub fn read_step(text: &str, tol: Tolerances) -> OgeomResult<StepImport> {
159    let (_, instances) = super::parse::parse_instances(text)?;
160    let mut reader = Reader {
161        instances: &instances,
162        model: Model::new(),
163        report: StepReport {
164            scale_mm: 1.0,
165            ..StepReport::default()
166        },
167        angle_scale: 1.0,
168        visited: Visited::for_instances(&instances),
169        vertices: FastMap::default(),
170        edges: FastMap::default(),
171        faces: FastMap::default(),
172        callout_index: FastMap::default(),
173        pcurves: FastMap::default(),
174        pieces: FastMap::default(),
175        band_faces: FastSet::default(),
176        unnamed_colours: FastSet::default(),
177        pole_vertices: Vec::new(),
178        piece_key: u64::MAX,
179        untrimmed_ids: Vec::new(),
180        tallies: FastMap::default(),
181        cdsr_of_nauo: None,
182        properties_of_definition: None,
183        sdrs_of_property: None,
184        depth: 0,
185        tol,
186    };
187    reader.report.scale_mm = reader.unit_scale();
188    reader.angle_scale = reader.angle_unit_scale();
189
190    let mut solids = Vec::new();
191    let mut shells = Vec::new();
192    let mut by_item: FastMap<u64, Shape> = FastMap::default();
193    // Solids and surface models, one walk in file order; a body is a body
194    // to the progress bar whichever kind it is.
195    let mut ids: Vec<(u64, bool)> = instances
196        .iter()
197        .filter_map(|(id, inst)| {
198            if inst.part("MANIFOLD_SOLID_BREP").is_some() || inst.part("BREP_WITH_VOIDS").is_some()
199            {
200                Some((id, true))
201            } else if inst.part("SHELL_BASED_SURFACE_MODEL").is_some() {
202                Some((id, false))
203            } else {
204                None
205            }
206        })
207        .collect();
208    ids.sort_unstable();
209    let total = ids.len() as u64;
210    for (done, (id, is_solid)) in ids.into_iter().enumerate() {
211        ogeom_core::progress::checkpoint()?;
212        ogeom_core::progress::stage_at("step: solid", done as u64 + 1, total);
213        if is_solid {
214            let solid = reader.solid(id)?;
215            by_item.insert(id, solid.clone());
216            solids.push(solid);
217        } else {
218            let shell = reader.surface_model(id)?;
219            by_item.insert(id, shell.clone());
220            shells.push(shell);
221        }
222    }
223    // Every widening the reader made (an edge's tolerance recording how far
224    // its pcurves sit from its curve) reaches the vertices that bound it,
225    // and a face's reaches its edges: the containment rule the checker holds
226    // a solid to, kept by the reader that built it.
227    for body in solids.iter().chain(shells.iter()) {
228        ogeom_algo::restore_containment(&mut reader.model, body)?;
229    }
230    // The tallies fold into the summary, largest first, ties by kind so the
231    // order is the file's and not the map's.
232    {
233        let mut entries: Vec<WarningSummary> = reader
234            .tallies
235            .drain()
236            .map(|(kind, (count, worst, exemplar))| WarningSummary {
237                kind,
238                count,
239                worst,
240                exemplar,
241            })
242            .collect();
243        entries.sort_by(|a, b| b.count.cmp(&a.count).then(a.kind.cmp(b.kind)));
244        reader.report.summary = entries;
245    }
246    // The noted refusals resolve to the faces themselves, once they
247    // exist: the same cache the shells were assembled from answers by the
248    // very id the warnings name.
249    for id in std::mem::take(&mut reader.untrimmed_ids) {
250        if let Some(face) = reader.faces.get(&id) {
251            reader.report.untrimmed_faces.push(UntrimmedFace {
252                entity: id,
253                face: face.clone(),
254            });
255        }
256    }
257    crate::bodies::rechart_beside_poles(
258        &mut reader.model,
259        solids.iter().chain(shells.iter()),
260        &mut reader.report.warnings,
261        tol,
262    )?;
263    let construction = reader.construction_sets()?;
264    if solids.is_empty() && shells.is_empty() && construction.is_empty() {
265        ogeom_bail!(
266            Construction,
267            "the exchange file contains no MANIFOLD_SOLID_BREP, \
268             BREP_WITH_VOIDS, SHELL_BASED_SURFACE_MODEL or geometric set to read"
269        );
270    }
271    // Each face read, by the file's id for it, as the bodies hold it: a face
272    // the reader turned or rebuilt after first building it is the one its
273    // map holds last, and the body's own occurrence is handed out.
274    let mut held: FastMap<ogeom_topo::TShapeId, Shape> = FastMap::default();
275    for body in solids.iter().chain(shells.iter()) {
276        for face in ogeom_topo::explore_unique(&reader.model, body, ogeom_topo::ShapeType::Face)? {
277            held.entry(face.node()).or_insert(face);
278        }
279    }
280    let mut faces: Vec<(u64, Shape)> = reader
281        .faces
282        .iter()
283        .filter_map(|(&id, face)| Some((id, held.get(&face.node())?.clone())))
284        .collect();
285    faces.sort_unstable_by_key(|&(id, _)| id);
286    let document = reader.document(&by_item, &solids, &shells, construction)?;
287
288    // Everything never visited, counted by its leading keyword.
289    for (id, instance) in instances.iter() {
290        if !reader.visited.seen(id) {
291            *reader
292                .report
293                .skipped
294                .entry(instance.keyword().to_owned())
295                .or_default() += 1;
296        }
297    }
298
299    Ok(StepImport {
300        document,
301        solids,
302        shells,
303        faces,
304        report: reader.report,
305    })
306}
307
308/// Named construction geometry by the representation holding it, in file
309/// order.
310type ConstructionSets = Vec<(u64, Vec<(String, ConstructionGeometry)>)>;
311
312/// An edge as built: the shape, its curve and range, and whether STEP's edge
313/// direction runs against the curve.
314type BuiltEdge = (Shape, Curve, (f64, f64), bool);
315
316/// A pcurve derived ahead of the face that needs it.
317///
318/// Deriving one is a pure function of a curve, its range and a surface (no
319/// model, no order), and it is most of the time spent building solids. So
320/// it is done for a whole solid at once, off the walk that attaches it.
321/// An `off` within the confusion distance is only known to be within it:
322/// the edge is widened only past it.
323enum PreparedPcurve {
324    /// The file stated it, and it stands `off` from the curve once lifted,
325    /// within what the edge's own tolerance allows.
326    Stated { curve: PlanarCurve, off: f64 },
327    /// The projection had a closed form, standing `off` from the curve
328    /// once lifted.
329    Exact { curve: PlanarCurve, off: f64 },
330    /// It did not, and this is the fit, with what the fit cost and how far
331    /// it stands from the curve once lifted.
332    Fitted {
333        curve: PlanarCurve,
334        error: f64,
335        met: bool,
336        worst_off: f64,
337        off: f64,
338        warning: Option<String>,
339    },
340    /// Neither worked, for this reason; the face gets the warning.
341    Refused(String),
342}
343
344struct Reader<'a> {
345    instances: &'a Instances,
346    model: Model,
347    report: StepReport,
348    /// Radians per file angle unit; degrees are common.
349    angle_scale: f64,
350    /// Which instances the reader has looked at.
351    visited: Visited,
352    vertices: FastMap<u64, Shape>,
353    edges: FastMap<u64, BuiltEdge>,
354    faces: FastMap<u64, Shape>,
355    /// STEP id → index into the callouts just built, for the views pass.
356    callout_index: FastMap<u64, usize>,
357    /// `(face, edge)` → the pcurve already derived for it, from the parallel
358    /// pass at the head of each solid.
359    /// The flag says whether the file stated a pcurve for it.
360    pcurves: FastMap<(u64, u64), (PreparedPcurve, bool)>,
361    /// Edge → the pieces it was cut into where its curve runs through a
362    /// pole of a face it bounds, each under a key of its own that stands in
363    /// for the edge's id wherever a face's bound names it.
364    pieces: FastMap<u64, Vec<(u64, BuiltEdge)>>,
365    /// Faces bounded by two whole circles of their surface whose rims share
366    /// a vertex column only because one rim was split on the other's: each
367    /// builds as a band of rings through its common column.
368    band_faces: FastSet<u64>,
369    /// The `DRAUGHTING_PRE_DEFINED_COLOUR`s whose name is none of the eight
370    /// the standard defines, warned of once each.
371    unnamed_colours: FastSet<u64>,
372    /// The vertices made at poles, shared by every piece that meets one.
373    pole_vertices: Vec<Shape>,
374    /// The last key handed to a piece; keys count down from the top of the
375    /// range, clear of every file id.
376    piece_key: u64,
377    /// Faces noted untrimmed, by file id; resolved to shapes once the read
378    /// is far enough along for the shapes to exist.
379    untrimmed_ids: Vec<u64>,
380    /// kind → (count, worst, exemplar), folded into the report's summary.
381    tallies: FastMap<&'static str, (usize, f64, u64)>,
382    /// Usage → its `CONTEXT_DEPENDENT_SHAPE_REPRESENTATION`, built once,
383    /// so an assembly edge is a lookup rather than a scan of the whole
384    /// exchange, which is O(usages × entities).
385    cdsr_of_nauo: Option<FastMap<u64, u64>>,
386    /// Definition → its `PROPERTY_DEFINITION`s, and property → its
387    /// `SHAPE_DEFINITION_REPRESENTATION`s, built together once, so a datum
388    /// target's lookup is not a scan of the whole exchange per property
389    /// *per target*. Each list ascends by id, so the first entry that
390    /// answers is the lowest id, the one a scan in file order reaches first.
391    properties_of_definition: Option<FastMap<u64, Vec<u64>>>,
392    sdrs_of_property: Option<FastMap<u64, Vec<u64>>>,
393    /// How many geometry or shell builders are open on the stack. Entities
394    /// refer to entities of their own kind (an offset surface to its basis,
395    /// an oriented shell to its shell), so a file that refers in a circle
396    /// would otherwise recurse until the stack runs out.
397    depth: u32,
398    tol: Tolerances,
399}
400
401/// How deep entities of one kind may refer to one another. Real files nest
402/// a handful of levels (a trimmed curve of an offset of a replica); a chain
403/// this long is a cycle or a hostile file.
404const MOST_NESTING: u32 = 64;
405
406impl<'a> Reader<'a> {
407    /// Run a builder one level deeper, refusing past [`MOST_NESTING`].
408    fn nested<T>(&mut self, build: impl FnOnce(&mut Self) -> OgeomResult<T>) -> OgeomResult<T> {
409        if self.depth >= MOST_NESTING {
410            ogeom_bail!(
411                Construction,
412                "entities refer to one another more than {MOST_NESTING} deep; \
413                 the file refers in a circle"
414            );
415        }
416        self.depth += 1;
417        let built = build(self);
418        self.depth -= 1;
419        built
420    }
421
422    /// The instance behind an id. Borrowed from the exchange, not from the
423    /// reader, so its arguments can be read while the reader builds.
424    fn instance(&mut self, id: u64) -> OgeomResult<&'a Instance> {
425        self.visited.mark(id);
426        let instances = self.instances;
427        instances.get(id).ok_or_else(|| {
428            ogeom_core::ogeom_err!(
429                Construction,
430                "the file references #{id}, which does not exist"
431            )
432        })
433    }
434
435    /// A face's arguments (name, bounds, surface, sense), whether the file
436    /// wrote it as the `ADVANCED_FACE` every modern writer uses or as the
437    /// plain `FACE_SURFACE` it specialises, which carries the same four.
438    fn face_args(&mut self, id: u64) -> OgeomResult<&'a [Arg]> {
439        let instance = self.instance(id)?;
440        if let Some(args) = instance
441            .part("ADVANCED_FACE")
442            .or_else(|| instance.part("FACE_SURFACE"))
443        {
444            return Ok(args);
445        }
446        ogeom_bail!(
447            Construction,
448            "#{id} is {}, where a face (ADVANCED_FACE or FACE_SURFACE) was needed",
449            instance.keyword()
450        );
451    }
452
453    fn args(&mut self, id: u64, keyword: &str) -> OgeomResult<&'a [Arg]> {
454        let instance = self.instance(id)?;
455        let Some(args) = instance.part(keyword) else {
456            ogeom_bail!(
457                Construction,
458                "#{id} is {}, where {keyword} was needed",
459                instance.keyword()
460            );
461        };
462        Ok(args)
463    }
464
465    // --- units ---------------------------------------------------------------
466
467    /// The unit instances the representation context actually assigns.
468    ///
469    /// A file may carry both a radian and a degree (definition and
470    /// conversion), and which applies is not a matter of existence but of
471    /// assignment: `GLOBAL_UNIT_ASSIGNED_CONTEXT` lists the ones in force.
472    fn assigned_units(&self) -> Vec<u64> {
473        // A file may carry several unit contexts (inches for the model,
474        // millimetres for its annotation sheet), and the shapes' own
475        // representations name the one their coordinates mean. That context
476        // goes first; the rest follow in entity order, so the answer never
477        // depends on how a map happens to iterate.
478        let mut cited: Vec<u64> = self
479            .instances
480            .values()
481            .filter(|inst| {
482                inst.parts
483                    .iter()
484                    .any(|(k, _)| k.ends_with("SHAPE_REPRESENTATION"))
485            })
486            .filter_map(|inst| {
487                inst.parts
488                    .iter()
489                    .find(|(k, _)| k.ends_with("SHAPE_REPRESENTATION"))
490                    .and_then(|(_, args)| args.last())
491                    .and_then(Arg::reference)
492            })
493            .collect();
494        cited.sort_unstable();
495        cited.dedup();
496
497        let mut contexts: Vec<u64> = self
498            .instances
499            .iter()
500            .filter(|(_, inst)| inst.part("GLOBAL_UNIT_ASSIGNED_CONTEXT").is_some())
501            .map(|(id, _)| id)
502            .collect();
503        contexts.sort_unstable();
504        contexts.sort_by_cached_key(|id| !cited.contains(id));
505
506        let mut out = Vec::new();
507        for id in contexts {
508            if let Some(instance) = self.instances.get(id)
509                && let Some(args) = instance.part("GLOBAL_UNIT_ASSIGNED_CONTEXT")
510            {
511                for arg in args.iter().filter_map(Arg::list).flatten() {
512                    if let Some(r) = arg.reference() {
513                        out.push(r);
514                    }
515                }
516            }
517        }
518        out
519    }
520
521    /// Millimetres per file length unit, from the units the context assigns.
522    fn unit_scale(&mut self) -> f64 {
523        let assigned = self.assigned_units();
524        for id in assigned {
525            let Some(instance) = self.instances.get(id) else {
526                continue;
527            };
528            let instance = instance.clone();
529            if instance.part("LENGTH_UNIT").is_none() {
530                continue;
531            }
532            if let Some(args) = instance.part("SI_UNIT") {
533                return match args.first() {
534                    Some(Arg::Enum(prefix)) => match prefix.as_str() {
535                        "MILLI" => 1.0,
536                        "CENTI" => 10.0,
537                        "DECI" => 100.0,
538                        "KILO" => 1e6,
539                        "MICRO" => 1e-3,
540                        other => {
541                            self.report
542                                .warnings
543                                .push(format!("#{id}: unknown SI prefix {other}; taking metres"));
544                            1000.0
545                        }
546                    },
547                    _ => 1000.0,
548                };
549            }
550            if let Some(args) = instance.part("CONVERSION_BASED_UNIT")
551                && let Some(measure) = args.get(1).and_then(Arg::reference)
552                && let Some(inner) = self.instances.get(measure)
553            {
554                let factor = inner
555                    .parts
556                    .iter()
557                    .flat_map(|(_, a)| a.iter())
558                    .find_map(|a| match a {
559                        Arg::Typed(typed) if typed.0 == "LENGTH_MEASURE" => {
560                            typed.1.first().and_then(Arg::number)
561                        }
562                        _ => None,
563                    });
564                let base = inner
565                    .parts
566                    .iter()
567                    .flat_map(|(_, a)| a.iter())
568                    .find_map(Arg::reference)
569                    .and_then(|b| self.instances.get(b))
570                    .and_then(|u| u.part("SI_UNIT"))
571                    .map_or(1000.0, |args| match args.first() {
572                        Some(Arg::Enum(p)) if p == "MILLI" => 1.0,
573                        Some(Arg::Enum(p)) if p == "CENTI" => 10.0,
574                        _ => 1000.0,
575                    });
576                if let Some(f) = factor {
577                    return f * base;
578                }
579            }
580        }
581        self.report
582            .warnings
583            .push("no length unit found; taking millimetres".to_owned());
584        1.0
585    }
586
587    /// Radians per file angle unit: the same dance as length, for the files
588    /// that measure their cones in degrees.
589    fn angle_unit_scale(&mut self) -> f64 {
590        let assigned = self.assigned_units();
591        for id in assigned {
592            let Some(instance) = self.instances.get(id) else {
593                continue;
594            };
595            let instance = instance.clone();
596            if instance.part("PLANE_ANGLE_UNIT").is_none() {
597                continue;
598            }
599            if instance.part("SI_UNIT").is_some() {
600                return 1.0;
601            }
602            if let Some(args) = instance.part("CONVERSION_BASED_UNIT")
603                && let Some(measure) = args.get(1).and_then(Arg::reference)
604                && let Some(inner) = self.instances.get(measure)
605            {
606                let factor = inner
607                    .parts
608                    .iter()
609                    .flat_map(|(_, a)| a.iter())
610                    .find_map(|a| match a {
611                        Arg::Typed(typed) if typed.0 == "PLANE_ANGLE_MEASURE" => {
612                            typed.1.first().and_then(Arg::number)
613                        }
614                        _ => None,
615                    });
616                if let Some(f) = factor {
617                    return f;
618                }
619            }
620        }
621        1.0
622    }
623
624    // --- geometry ------------------------------------------------------------
625
626    fn point(&mut self, id: u64) -> OgeomResult<Point> {
627        let args = self.args(id, "CARTESIAN_POINT")?;
628        let Some(coords) = args.get(1).and_then(Arg::list) else {
629            ogeom_bail!(Construction, "#{id}: a point without coordinates");
630        };
631        let scale = self.report.scale_mm;
632        let value = |i: usize| coords.get(i).and_then(Arg::number).unwrap_or(0.0) * scale;
633        Ok(Point::new(value(0), value(1), value(2)))
634    }
635
636    fn direction(&mut self, id: u64) -> OgeomResult<Direction> {
637        let args = self.args(id, "DIRECTION")?;
638        let Some(coords) = args.get(1).and_then(Arg::list) else {
639            ogeom_bail!(Construction, "#{id}: a direction without components");
640        };
641        let value = |i: usize| coords.get(i).and_then(Arg::number).unwrap_or(0.0);
642        Direction::new(Vector::new(value(0), value(1), value(2)), self.tol)
643    }
644
645    /// An `AXIS2_PLACEMENT_3D` as a frame, with the standard's defaults for
646    /// what the file leaves out.
647    fn frame(&mut self, id: u64) -> OgeomResult<Frame> {
648        let args = self.args(id, "AXIS2_PLACEMENT_3D")?;
649        let origin = args
650            .get(1)
651            .and_then(Arg::reference)
652            .map(|r| self.point(r))
653            .transpose()?
654            .unwrap_or(Point::ORIGIN);
655        let z = args
656            .get(2)
657            .and_then(Arg::reference)
658            .map(|r| self.direction(r))
659            .transpose()?
660            .unwrap_or(Direction::Z);
661        let x = match args.get(3).and_then(Arg::reference) {
662            Some(r) => self.direction(r)?,
663            None => Direction::from_cross(z.vector(), Vector::new(0.31, 0.52, 0.8), self.tol)?,
664        };
665        Frame::new(origin, z, x, self.tol)
666    }
667
668    fn surface(&mut self, id: u64) -> OgeomResult<Option<SurfaceGeometry>> {
669        self.nested(|reader| reader.surface_at(id))
670    }
671
672    fn surface_at(&mut self, id: u64) -> OgeomResult<Option<SurfaceGeometry>> {
673        let (keyword, args) = {
674            let instance = self.instance(id)?;
675            (
676                instance.keyword(),
677                instance
678                    .parts
679                    .first()
680                    .map_or(&[][..], |(_, a)| a.as_slice()),
681            )
682        };
683        let scale = self.report.scale_mm;
684        let radius_arg = |args: &[Arg], i: usize| args.get(i).and_then(Arg::number);
685        // B-spline surfaces arrive two ways: a simple instance with every
686        // attribute in one list, or a complex instance whose parts each
687        // carry their own slice, the rational form always the latter.
688        {
689            let (base, knots_part, weights) = {
690                let instance = self.instance(id)?;
691                (
692                    instance.part("B_SPLINE_SURFACE"),
693                    instance.part("B_SPLINE_SURFACE_WITH_KNOTS"),
694                    instance.part("RATIONAL_B_SPLINE_SURFACE"),
695                )
696            };
697            if let Some(kp) = knots_part {
698                let (degrees, grid_arg, mults_knots) = if let Some(base) = base {
699                    (
700                        (base.first().cloned(), base.get(1).cloned()),
701                        base.get(2).cloned(),
702                        (
703                            kp.first().cloned(),
704                            kp.get(1).cloned(),
705                            kp.get(2).cloned(),
706                            kp.get(3).cloned(),
707                        ),
708                    )
709                } else {
710                    (
711                        (kp.get(1).cloned(), kp.get(2).cloned()),
712                        kp.get(3).cloned(),
713                        (
714                            kp.get(8).cloned(),
715                            kp.get(9).cloned(),
716                            kp.get(10).cloned(),
717                            kp.get(11).cloned(),
718                        ),
719                    )
720                };
721                return self
722                    .bspline_surface(id, degrees, grid_arg, mults_knots, weights, None)
723                    .map(Some);
724            }
725        }
726        // A complex instance whose knots are implied by its form, or a
727        // simple one of those forms.
728        {
729            let (base, weights, form) = {
730                let instance = self.instance(id)?;
731                (
732                    instance.part("B_SPLINE_SURFACE"),
733                    instance.part("RATIONAL_B_SPLINE_SURFACE"),
734                    ["BEZIER_SURFACE", "UNIFORM_SURFACE", "QUASI_UNIFORM_SURFACE"]
735                        .into_iter()
736                        .find(|k| instance.part(k).is_some()),
737                )
738            };
739            if let (Some(base), Some(form)) = (base, form) {
740                return self
741                    .bspline_surface(
742                        id,
743                        (base.first().cloned(), base.get(1).cloned()),
744                        base.get(2).cloned(),
745                        (None, None, None, None),
746                        weights,
747                        Some(form),
748                    )
749                    .map(Some);
750            }
751            if let Some(form) = ["BEZIER_SURFACE", "UNIFORM_SURFACE", "QUASI_UNIFORM_SURFACE"]
752                .into_iter()
753                .find(|k| *k == keyword)
754            {
755                return self
756                    .bspline_surface(
757                        id,
758                        (args.get(1).cloned(), args.get(2).cloned()),
759                        args.get(3).cloned(),
760                        (None, None, None, None),
761                        None,
762                        Some(form),
763                    )
764                    .map(Some);
765            }
766        }
767        let out = match keyword {
768            "PLANE" => {
769                let frame = self.frame(ref_at(args, 1))?;
770                Some(
771                    PlaneSurface::over(
772                        Plane::new(frame),
773                        (-SURFACE_EXTENT, SURFACE_EXTENT),
774                        (-SURFACE_EXTENT, SURFACE_EXTENT),
775                    )?
776                    .into(),
777                )
778            }
779            "CYLINDRICAL_SURFACE" => {
780                let frame = self.frame(ref_at(args, 1))?;
781                let radius = radius_arg(args, 2).unwrap_or(0.0) * scale;
782                Some(
783                    CylinderSurface::new(
784                        Cylinder::new(frame, radius, self.tol)?,
785                        (-SURFACE_EXTENT, SURFACE_EXTENT),
786                    )?
787                    .into(),
788                )
789            }
790            "CONICAL_SURFACE" => {
791                let frame = self.frame(ref_at(args, 1))?;
792                let radius = radius_arg(args, 2).unwrap_or(0.0) * scale;
793                let angle = radius_arg(args, 3).unwrap_or(0.0) * self.angle_scale;
794                Some(
795                    ConeSurface::new(
796                        Cone::new(frame, radius, angle, self.tol)?,
797                        (-SURFACE_EXTENT, SURFACE_EXTENT),
798                    )?
799                    .into(),
800                )
801            }
802            "SPHERICAL_SURFACE" => {
803                let frame = self.frame(ref_at(args, 1))?;
804                let radius = radius_arg(args, 2).unwrap_or(0.0) * scale;
805                Some(SphereSurface::new(Sphere::new(frame, radius, self.tol)?).into())
806            }
807            "TOROIDAL_SURFACE" => {
808                let frame = self.frame(ref_at(args, 1))?;
809                let major = radius_arg(args, 2).unwrap_or(0.0) * scale;
810                let minor = radius_arg(args, 3).unwrap_or(0.0) * scale;
811                Some(TorusSurface::new(Torus::new(frame, major, minor, self.tol)?).into())
812            }
813            "SURFACE_OF_LINEAR_EXTRUSION" => {
814                // A curve swept along a vector, unbounded either way. A
815                // file writes a drum's wall this way as often as it writes a
816                // cylinder (a circle swept along its own axis) and a wall
817                // as a line swept: those are read as the cylinder and the
818                // plane they are, exact and known everywhere downstream.
819                // Anything else sweeps as itself, over a window the face's
820                // own edges then widen to fit.
821                let Some(curve) = self.curve(ref_at(args, 1))? else {
822                    self.report.warnings.push(format!(
823                        "#{id}: an extrusion's swept curve is not read; its face is skipped"
824                    ));
825                    return Ok(None);
826                };
827                let vector = self.args(ref_at(args, 2), "VECTOR")?;
828                let direction = self.direction(vector[1].reference().unwrap_or(0))?;
829                // Parallel to the file's own precision in directions: a
830                // writer states an axis to nine digits, a whisker off the
831                // sweep it meant to be exactly along.
832                let parallel =
833                    |axis: Direction| axis.vector().cross(direction.vector()).magnitude() <= 1e-6;
834                match &curve {
835                    Curve::Circle(c) if parallel(c.circle().frame().z()) => Some(
836                        CylinderSurface::new(
837                            Cylinder::new(c.circle().frame(), c.circle().radius(), self.tol)?,
838                            (-SURFACE_EXTENT, SURFACE_EXTENT),
839                        )?
840                        .into(),
841                    ),
842                    Curve::Line(l) if !parallel(l.axis().direction) => {
843                        let axis = l.axis();
844                        let normal = Direction::from_cross(
845                            axis.direction.vector(),
846                            direction.vector(),
847                            self.tol,
848                        )?;
849                        let frame = Frame::new(axis.location, normal, axis.direction, self.tol)?;
850                        Some(
851                            PlaneSurface::over(
852                                Plane::new(frame),
853                                (-SURFACE_EXTENT, SURFACE_EXTENT),
854                                (-SURFACE_EXTENT, SURFACE_EXTENT),
855                            )?
856                            .into(),
857                        )
858                    }
859                    _ => Some(
860                        ExtrusionSurface::over(
861                            curve,
862                            direction,
863                            (-SURFACE_EXTENT, SURFACE_EXTENT),
864                        )?
865                        .into(),
866                    ),
867                }
868            }
869            "SURFACE_OF_REVOLUTION" => {
870                let Some(curve) = self.curve(ref_at(args, 1))? else {
871                    self.report.warnings.push(format!(
872                        "#{id}: a revolution's swept curve is not read; its face is skipped"
873                    ));
874                    return Ok(None);
875                };
876                let placement = self.args(ref_at(args, 2), "AXIS1_PLACEMENT")?;
877                let location = self.point(placement[1].reference().unwrap_or(0))?;
878                let direction = match placement.get(2).and_then(Arg::reference) {
879                    Some(r) => self.direction(r)?,
880                    None => Direction::Z,
881                };
882                Some(
883                    ogeom_geom::RevolutionSurface::new(
884                        curve,
885                        Axis {
886                            location,
887                            direction,
888                        },
889                        core::f64::consts::TAU,
890                    )?
891                    .into(),
892                )
893            }
894            "OFFSET_SURFACE" => {
895                let Some(basis) = self.surface(ref_at(args, 1))? else {
896                    return Ok(None);
897                };
898                let distance = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
899                if distance == 0.0 {
900                    return Ok(Some(basis));
901                }
902                // An analytic basis offsets to the analytic surface it is,
903                // which every downstream path speaks exactly.
904                let offset = ogeom_geom::OffsetSurface::new(basis, distance)?;
905                Some(match offset.analytic(self.tol)? {
906                    Some(analytic) => analytic,
907                    None => SurfaceGeometry::Offset(Box::new(offset)),
908                })
909            }
910            // A face's own edges bound it; the window these name is the
911            // basis's, restated.
912            "RECTANGULAR_TRIMMED_SURFACE" | "CURVE_BOUNDED_SURFACE" => {
913                self.surface(ref_at(args, 1))?
914            }
915            "DEGENERATE_TOROIDAL_SURFACE" => {
916                let frame = self.frame(ref_at(args, 1))?;
917                let major = radius_arg(args, 2).unwrap_or(0.0) * scale;
918                let minor = radius_arg(args, 3).unwrap_or(0.0) * scale;
919                Some(TorusSurface::new(Torus::new(frame, major, minor, self.tol)?).into())
920            }
921            "RECTANGULAR_COMPOSITE_SURFACE" => Some(self.composite_surface(id, args)?),
922            "SURFACE_REPLICA" => {
923                let Some(parent) = self.surface(ref_at(args, 1))? else {
924                    return Ok(None);
925                };
926                let motion = self.transformation_operator(ref_at(args, 2))?;
927                Some(parent.transformed(&motion, self.tol)?)
928            }
929            other => {
930                self.report.warnings.push(format!(
931                    "#{id}: surface kind {other} is not read yet; its face is skipped"
932                ));
933                None
934            }
935        };
936        Ok(out)
937    }
938
939    /// Expand STEP's multiplicity-compressed knots.
940    fn expand_knots(mults: Option<Arg>, knots: Option<Arg>) -> OgeomResult<Vec<f64>> {
941        let (Some(Arg::List(mults)), Some(Arg::List(knots))) = (mults, knots) else {
942            return Ok(Vec::new());
943        };
944        expanded_knots(&mults, &knots)
945    }
946
947    #[allow(clippy::type_complexity)]
948    fn bspline_surface(
949        &mut self,
950        id: u64,
951        degrees: (Option<Arg>, Option<Arg>),
952        grid_arg: Option<Arg>,
953        mults_knots: (Option<Arg>, Option<Arg>, Option<Arg>, Option<Arg>),
954        weights: Option<&[Arg]>,
955        form: Option<&str>,
956    ) -> OgeomResult<SurfaceGeometry> {
957        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
958        let (u_degree, v_degree) = (
959            file_degree(degrees.0.as_ref(), 1.0)?,
960            file_degree(degrees.1.as_ref(), 1.0)?,
961        );
962        let Some(Arg::List(rows)) = grid_arg else {
963            ogeom_bail!(
964                Construction,
965                "#{id}: a b-spline surface without control points"
966            );
967        };
968        let mut points = Vec::new();
969        let (mut u_count, mut v_count) = (0, 0);
970        for row in &rows {
971            let Some(cells) = row.list() else { continue };
972            u_count += 1;
973            v_count = cells.len();
974            for cell in cells {
975                points.push(self.point(cell.reference().unwrap_or(0))?);
976            }
977        }
978        let (u_raw, v_raw) = match form {
979            Some(form) => (
980                implied_knots(form, u_degree, u_count),
981                implied_knots(form, v_degree, v_count),
982            ),
983            None => (
984                Self::expand_knots(mults_knots.0, mults_knots.2)?,
985                Self::expand_knots(mults_knots.1, mults_knots.3)?,
986            ),
987        };
988        let u_knots = KnotVector::new(u_raw, u_degree)?;
989        let v_knots = KnotVector::new(v_raw, v_degree)?;
990        let surface = if let Some(weights) = weights {
991            let flat: Vec<f64> = weights
992                .first()
993                .and_then(Arg::list)
994                .unwrap_or(&[])
995                .iter()
996                .filter_map(Arg::list)
997                .flatten()
998                .filter_map(Arg::number)
999                .collect();
1000            let weighted: Vec<ogeom_math::Weighted<Point>> = points
1001                .iter()
1002                .zip(flat.iter().chain(std::iter::repeat(&1.0)))
1003                .map(|(p, w)| ogeom_math::Weighted::new(*p, *w, self.tol))
1004                .collect::<OgeomResult<_>>()?;
1005            ogeom_geom::BSplineSurface::rational(
1006                u_knots,
1007                v_knots,
1008                ogeom_math::ControlGrid::new(weighted, u_count, v_count)?,
1009            )?
1010        } else {
1011            ogeom_geom::BSplineSurface::new(
1012                u_knots,
1013                v_knots,
1014                &ogeom_math::ControlGrid::new(points, u_count, v_count)?,
1015                self.tol,
1016            )?
1017        };
1018        Ok(surface.into())
1019    }
1020
1021    fn curve(&mut self, id: u64) -> OgeomResult<Option<Curve>> {
1022        self.nested(|reader| reader.curve_at(id))
1023    }
1024
1025    fn curve_at(&mut self, id: u64) -> OgeomResult<Option<Curve>> {
1026        let (keyword, args) = {
1027            let instance = self.instance(id)?;
1028            (
1029                instance.keyword(),
1030                instance
1031                    .parts
1032                    .first()
1033                    .map_or(&[][..], |(_, a)| a.as_slice()),
1034            )
1035        };
1036        let scale = self.report.scale_mm;
1037        {
1038            let (base, kp, weights) = {
1039                let instance = self.instance(id)?;
1040                (
1041                    instance.part("B_SPLINE_CURVE"),
1042                    instance.part("B_SPLINE_CURVE_WITH_KNOTS"),
1043                    instance.part("RATIONAL_B_SPLINE_CURVE"),
1044                )
1045            };
1046            // A complex instance: the base part carries degree and control
1047            // points; the knots come from the knots part or are implied by
1048            // the form (Bézier, uniform, quasi-uniform); weights from the
1049            // rational part where there is one.
1050            let form = {
1051                let instance = self.instance(id)?;
1052                ["BEZIER_CURVE", "UNIFORM_CURVE", "QUASI_UNIFORM_CURVE"]
1053                    .into_iter()
1054                    .find(|k| instance.part(k).is_some())
1055            };
1056            if let Some(base) = base
1057                && (kp.is_some() || form.is_some())
1058            {
1059                let degree = file_degree(base.first(), 1.0)?;
1060                let control: Vec<Point> = base
1061                    .get(1)
1062                    .and_then(Arg::list)
1063                    .unwrap_or(&[])
1064                    .iter()
1065                    .filter_map(Arg::reference)
1066                    .map(|r| self.point(r))
1067                    .collect::<OgeomResult<_>>()?;
1068                let knots = match (&kp, form) {
1069                    (Some(kp), _) => Self::expand_knots(kp.first().cloned(), kp.get(1).cloned())?,
1070                    (None, Some(form)) => implied_knots(form, degree, control.len()),
1071                    (None, None) => unreachable!("guarded above"),
1072                };
1073                let knots = KnotVector::new(knots, degree)?;
1074                let flat: Vec<f64> = weights
1075                    .as_ref()
1076                    .and_then(|w| w.first())
1077                    .and_then(Arg::list)
1078                    .unwrap_or(&[])
1079                    .iter()
1080                    .filter_map(Arg::number)
1081                    .collect();
1082                let weighted: Vec<ogeom_math::Weighted<Point>> = control
1083                    .iter()
1084                    .zip(flat.iter().chain(std::iter::repeat(&1.0)))
1085                    .map(|(p, w)| ogeom_math::Weighted::new(*p, *w, self.tol))
1086                    .collect::<OgeomResult<_>>()?;
1087                return Ok(Some(BSplineCurve::rational(knots, weighted)?.into()));
1088            }
1089        }
1090        let out: Option<Curve> = match keyword {
1091            "LINE" => {
1092                let through = self.point(ref_at(args, 1))?;
1093                // The vector's magnitude scales STEP's parameter; ranges here
1094                // are re-derived from vertex geometry, so only the direction
1095                // matters.
1096                let vector = self.args(ref_at(args, 2), "VECTOR")?;
1097                let direction = self.direction(vector[1].reference().unwrap_or(0))?;
1098                Some(
1099                    LineCurve::new(Axis {
1100                        location: through,
1101                        direction,
1102                    })
1103                    .into(),
1104                )
1105            }
1106            "CIRCLE" => {
1107                let frame = self.frame(ref_at(args, 1))?;
1108                let radius = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
1109                Some(CircleCurve::new(Circle::new(frame, radius, self.tol)?).into())
1110            }
1111            "ELLIPSE" => {
1112                let frame = self.frame(ref_at(args, 1))?;
1113                let a = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
1114                let b = args.get(3).and_then(Arg::number).unwrap_or(0.0) * scale;
1115                Some(EllipseCurve::new(Ellipse::new(frame, a, b, self.tol)?).into())
1116            }
1117            "B_SPLINE_CURVE_WITH_KNOTS" => {
1118                let degree = file_degree(args.get(1), 0.0)?;
1119                let control: Vec<Point> = args
1120                    .get(2)
1121                    .and_then(Arg::list)
1122                    .unwrap_or(&[])
1123                    .iter()
1124                    .filter_map(Arg::reference)
1125                    .map(|r| self.point(r))
1126                    .collect::<OgeomResult<_>>()?;
1127                let mults = args.get(6).and_then(Arg::list).unwrap_or(&[]).to_vec();
1128                let knots = args.get(7).and_then(Arg::list).unwrap_or(&[]).to_vec();
1129                let expanded = expanded_knots(&mults, &knots)?;
1130                Some(
1131                    BSplineCurve::new(KnotVector::new(expanded, degree)?, control, self.tol)?
1132                        .into(),
1133                )
1134            }
1135            "SURFACE_CURVE" | "SEAM_CURVE" | "INTERSECTION_CURVE" => {
1136                // A curve dressed in its surface associations: what many
1137                // exporters write for every edge. The 3D curve is the first
1138                // argument; the pcurves are read where a face asks for them.
1139                self.curve(args.get(1).and_then(Arg::reference).unwrap_or(0))?
1140            }
1141            "BEZIER_CURVE" | "UNIFORM_CURVE" | "QUASI_UNIFORM_CURVE" => {
1142                let degree = file_degree(args.get(1), 1.0)?;
1143                let control: Vec<Point> = args
1144                    .get(2)
1145                    .and_then(Arg::list)
1146                    .unwrap_or(&[])
1147                    .iter()
1148                    .filter_map(Arg::reference)
1149                    .map(|r| self.point(r))
1150                    .collect::<OgeomResult<_>>()?;
1151                let knots = implied_knots(keyword, degree, control.len());
1152                Some(BSplineCurve::new(KnotVector::new(knots, degree)?, control, self.tol)?.into())
1153            }
1154            "HYPERBOLA" => {
1155                let frame = self.frame(ref_at(args, 1))?;
1156                let a = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
1157                let b = args.get(3).and_then(Arg::number).unwrap_or(0.0) * scale;
1158                // The branch's reach in its own parameter: cosh 20 carries a
1159                // hyperbola far past any part.
1160                Some(HyperbolaCurve::new(Hyperbola::new(frame, a, b, self.tol)?, 20.0)?.into())
1161            }
1162            "PARABOLA" => {
1163                let frame = self.frame(ref_at(args, 1))?;
1164                let focal = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
1165                Some(
1166                    ParabolaCurve::new(Parabola::new(frame, focal, self.tol)?, SURFACE_EXTENT)?
1167                        .into(),
1168                )
1169            }
1170            "TRIMMED_CURVE" => {
1171                // An edge's ends are its vertices; the trims restate them.
1172                self.curve(args.get(1).and_then(Arg::reference).unwrap_or(0))?
1173            }
1174            "OFFSET_CURVE_3D" => {
1175                let Some(basis) = self.curve(args.get(1).and_then(Arg::reference).unwrap_or(0))?
1176                else {
1177                    return Ok(None);
1178                };
1179                let distance = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
1180                let reference =
1181                    self.direction(args.get(4).and_then(Arg::reference).unwrap_or(0))?;
1182                Some(Curve::Offset(Box::new(ogeom_geom::OffsetCurve::new(
1183                    basis, distance, reference,
1184                )?)))
1185            }
1186            "POLYLINE" => {
1187                let points: Vec<Point> = args
1188                    .get(1)
1189                    .and_then(Arg::list)
1190                    .unwrap_or(&[])
1191                    .iter()
1192                    .filter_map(Arg::reference)
1193                    .map(|r| self.point(r))
1194                    .collect::<OgeomResult<_>>()?;
1195                if points.len() < 2 {
1196                    ogeom_bail!(Construction, "#{id}: a polyline of fewer than two points");
1197                }
1198                // Degree one through the points, a knot at each.
1199                let mut knots = vec![0.0];
1200                #[allow(clippy::cast_precision_loss)]
1201                knots.extend((0..points.len()).map(|i| i as f64));
1202                #[allow(clippy::cast_precision_loss)]
1203                knots.push((points.len() - 1) as f64);
1204                Some(BSplineCurve::new(KnotVector::new(knots, 1)?, points, self.tol)?.into())
1205            }
1206            "COMPOSITE_CURVE" => {
1207                // Its segments, each over its own trim and in its own
1208                // sense, as splines joined end to start.
1209                let mut joined: Option<(KnotVector, Vec<ogeom_math::Weighted<Point>>)> = None;
1210                let segments: Vec<u64> = args
1211                    .get(1)
1212                    .and_then(Arg::list)
1213                    .unwrap_or(&[])
1214                    .iter()
1215                    .filter_map(Arg::reference)
1216                    .collect();
1217                for segment in segments {
1218                    let sargs = self.args(segment, "COMPOSITE_CURVE_SEGMENT")?;
1219                    let same_sense = !sargs.get(1).is_some_and(|a| a.is_enum("F"));
1220                    let parent = sargs.get(2).and_then(Arg::reference).unwrap_or(0);
1221                    let Some((curve, range)) = self.bounded_curve(parent)? else {
1222                        return Ok(None);
1223                    };
1224                    let mut spline = curve.to_bspline_over(range, self.tol)?;
1225                    if !same_sense {
1226                        let (knots, control) =
1227                            ogeom_math::bspline::reverse(spline.knots(), spline.control_points());
1228                        spline = BSplineCurve::rational(knots, control)?;
1229                    }
1230                    joined = Some(match joined {
1231                        None => (spline.knots().clone(), spline.control_points().to_vec()),
1232                        Some(held) => join_splines(held, &spline, self.tol)?,
1233                    });
1234                }
1235                let Some((knots, control)) = joined else {
1236                    ogeom_bail!(Construction, "#{id}: a composite curve with no segments");
1237                };
1238                Some(BSplineCurve::rational(knots, control)?.into())
1239            }
1240            "CURVE_REPLICA" => {
1241                let Some(parent) = self.curve(args.get(1).and_then(Arg::reference).unwrap_or(0))?
1242                else {
1243                    return Ok(None);
1244                };
1245                let motion = self
1246                    .transformation_operator(args.get(2).and_then(Arg::reference).unwrap_or(0))?;
1247                Some(parent.transformed(&motion, self.tol)?)
1248            }
1249            other => {
1250                self.report.warnings.push(format!(
1251                    "#{id}: curve kind {other} is not read yet; its edge is skipped"
1252                ));
1253                None
1254            }
1255        };
1256        Ok(out)
1257    }
1258
1259    /// A rectangular composite surface: its grid of patches joined into one
1260    /// B-spline surface, so a face across the grid is a face on one surface
1261    /// and needs no rebuilding. Each patch in its spline form over its own
1262    /// bounds, turned where its sense says, raised to the grid's degrees,
1263    /// its knots unified with its column's and its row's, and joined to its
1264    /// neighbours on their shared boundary. Neighbours that do not meet
1265    /// there are refused by name.
1266    fn composite_surface(&mut self, id: u64, args: &[Arg]) -> OgeomResult<SurfaceGeometry> {
1267        let rows: Vec<Vec<u64>> = args
1268            .get(1)
1269            .and_then(Arg::list)
1270            .unwrap_or(&[])
1271            .iter()
1272            .map(|row| {
1273                row.list()
1274                    .unwrap_or(&[])
1275                    .iter()
1276                    .filter_map(Arg::reference)
1277                    .collect()
1278            })
1279            .collect();
1280        if rows.is_empty()
1281            || rows
1282                .iter()
1283                .any(|r| r.len() != rows[0].len() || r.is_empty())
1284        {
1285            ogeom_bail!(
1286                Construction,
1287                "#{id}: a composite surface's grid is not rectangular"
1288            );
1289        }
1290        let mut grid: Vec<Vec<Patch>> = Vec::with_capacity(rows.len());
1291        for row in &rows {
1292            let mut out = Vec::with_capacity(row.len());
1293            for &patch in row {
1294                let pargs = self.args(patch, "SURFACE_PATCH")?;
1295                let parent = pargs.get(1).and_then(Arg::reference).unwrap_or(0);
1296                let u_sense = !pargs.get(4).is_some_and(|a| a.is_enum("F"));
1297                let v_sense = !pargs.get(5).is_some_and(|a| a.is_enum("F"));
1298                let spline = self.bounded_surface_spline(parent)?;
1299                let mut p = Patch::of(&spline)?;
1300                if !u_sense {
1301                    p = p.reversed_u();
1302                }
1303                if !v_sense {
1304                    p = p.transposed().reversed_u().transposed();
1305                }
1306                out.push(p);
1307            }
1308            grid.push(out);
1309        }
1310        join_patches(grid, self.tol).map_err(|e| {
1311            ogeom_core::ogeom_err!(
1312                Construction,
1313                "#{id}: a composite surface's patches do not join: {e}"
1314            )
1315        })
1316    }
1317
1318    /// A composite's patch surface as a B-spline over its own bounds: a
1319    /// B-spline as it is; a rectangularly trimmed surface's basis over its
1320    /// window, in the file's reading of the basis's parameters.
1321    fn bounded_surface_spline(&mut self, id: u64) -> OgeomResult<ogeom_geom::BSplineSurface> {
1322        let keyword = self.instance(id)?.keyword().to_owned();
1323        if keyword == "RECTANGULAR_TRIMMED_SURFACE" {
1324            let args = self.args(id, "RECTANGULAR_TRIMMED_SURFACE")?;
1325            let basis_id = args.get(1).and_then(Arg::reference).unwrap_or(0);
1326            let Some(basis) = self.surface(basis_id)? else {
1327                ogeom_bail!(Construction, "#{id}: a patch's basis is not read");
1328            };
1329            let angular = matches!(
1330                self.instance(basis_id)?.keyword(),
1331                "CYLINDRICAL_SURFACE"
1332                    | "CONICAL_SURFACE"
1333                    | "SPHERICAL_SURFACE"
1334                    | "TOROIDAL_SURFACE"
1335            );
1336            let n = |i: usize| args.get(i).and_then(Arg::number).unwrap_or(0.0);
1337            let (u_scale, v_scale) = if angular {
1338                (self.angle_scale, self.report.scale_mm)
1339            } else {
1340                (self.report.scale_mm, self.report.scale_mm)
1341            };
1342            let (u1, u2) = (n(2) * u_scale, n(3) * u_scale);
1343            let (v1, v2) = (n(4) * v_scale, n(5) * v_scale);
1344            let window = ((u1.min(u2), u1.max(u2)), (v1.min(v2), v1.max(v2)));
1345            let trimmed = ogeom_geom::TrimmedSurface::new(basis, window.0, window.1, self.tol)?;
1346            return SurfaceGeometry::Trimmed(Box::new(trimmed)).to_bspline(self.tol);
1347        }
1348        let Some(surface) = self.surface(id)? else {
1349            ogeom_bail!(Construction, "#{id}: a patch's surface is not read");
1350        };
1351        surface.to_bspline(self.tol)
1352    }
1353
1354    /// A curve with the stretch of it an entity bounds: a trimmed curve's
1355    /// basis between its trims (a point where the file gives one, else its
1356    /// parameter in the file's own reading of the basis), in its own sense;
1357    /// any other curve over its own domain, which must be bounded.
1358    fn bounded_curve(&mut self, id: u64) -> OgeomResult<Option<(Curve, (f64, f64))>> {
1359        self.nested(|reader| reader.bounded_curve_at(id))
1360    }
1361
1362    fn bounded_curve_at(&mut self, id: u64) -> OgeomResult<Option<(Curve, (f64, f64))>> {
1363        let keyword = self.instance(id)?.keyword().to_owned();
1364        if keyword != "TRIMMED_CURVE" {
1365            let Some(curve) = self.curve(id)? else {
1366                return Ok(None);
1367            };
1368            let (lo, hi) = curve.domain();
1369            if !lo.is_finite() || !hi.is_finite() || hi - lo > SURFACE_EXTENT {
1370                ogeom_bail!(
1371                    Construction,
1372                    "#{id}: an unbounded curve cannot stand as a composite's segment"
1373                );
1374            }
1375            return Ok(Some((curve, (lo, hi))));
1376        }
1377        let args = self.args(id, "TRIMMED_CURVE")?;
1378        let basis_id = args.get(1).and_then(Arg::reference).unwrap_or(0);
1379        let Some(basis) = self.curve(basis_id)? else {
1380            return Ok(None);
1381        };
1382        let basis_keyword = self.instance(basis_id)?.keyword().to_owned();
1383        let forward = !args.get(4).is_some_and(|a| a.is_enum("F"));
1384        let mut ends = Vec::with_capacity(2);
1385        for trim in [args.get(2), args.get(3)] {
1386            let items = trim.and_then(Arg::list).unwrap_or(&[]).to_vec();
1387            let mut at: Option<Point> = None;
1388            for item in &items {
1389                if let Some(r) = item.reference() {
1390                    at = Some(self.point(r)?);
1391                }
1392            }
1393            if at.is_none() {
1394                for item in &items {
1395                    if let Arg::Typed(typed) = item
1396                        && typed.0 == "PARAMETER_VALUE"
1397                        && let Some(v) = typed.1.first().and_then(Arg::number)
1398                    {
1399                        at =
1400                            Some(self.step_parameter_point(&basis, &basis_keyword, basis_id, v)?);
1401                    }
1402                }
1403            }
1404            let Some(point) = at else {
1405                ogeom_bail!(
1406                    Construction,
1407                    "#{id}: a trim names neither point nor parameter"
1408                );
1409            };
1410            let t = match self.parameter_of(&basis, point) {
1411                Some(t) => t,
1412                None => project_on_curve(&basis, point, 256, self.tol)?.parameter,
1413            };
1414            ends.push(t);
1415        }
1416        let (mut a, mut b) = (ends[0], ends[1]);
1417        if !forward {
1418            core::mem::swap(&mut a, &mut b);
1419        }
1420        if basis.is_periodic() && b <= a {
1421            let (lo, hi) = basis.domain();
1422            b += hi - lo;
1423        }
1424        let range = if a <= b { (a, b) } else { (b, a) };
1425        let mut curve = basis;
1426        if !forward || a > b {
1427            // Travelled backward: the same points, the parameter turned.
1428            let spline = curve.to_bspline_over(range, self.tol)?;
1429            let (knots, control) =
1430                ogeom_math::bspline::reverse(spline.knots(), spline.control_points());
1431            curve = BSplineCurve::rational(knots, control)?.into();
1432            let domain = curve.domain();
1433            return Ok(Some((curve, domain)));
1434        }
1435        Ok(Some((curve, range)))
1436    }
1437
1438    /// Where the file's parameter `v` stands on a basis curve, read as the
1439    /// file reads it: a line's by its own vector's length, a conic's as an
1440    /// angle in the file's angle unit, a spline's as it is.
1441    fn step_parameter_point(
1442        &mut self,
1443        basis: &Curve,
1444        keyword: &str,
1445        basis_id: u64,
1446        v: f64,
1447    ) -> OgeomResult<Point> {
1448        match keyword {
1449            "LINE" => {
1450                let args = self.args(basis_id, "LINE")?;
1451                let through = self.point(ref_at(args, 1))?;
1452                let vector = self.args(ref_at(args, 2), "VECTOR")?;
1453                let direction = self.direction(vector[1].reference().unwrap_or(0))?;
1454                let magnitude = vector.get(2).and_then(Arg::number).unwrap_or(1.0);
1455                Ok(through + direction.vector() * (v * magnitude * self.report.scale_mm))
1456            }
1457            "CIRCLE" | "ELLIPSE" => basis.point_at(v * self.angle_scale, self.tol),
1458            _ => basis.point_at(v, self.tol),
1459        }
1460    }
1461
1462    /// A Cartesian transformation operator as the motion it states: the
1463    /// axes it names (completed square to one another), its origin, and
1464    /// its uniform scale.
1465    fn transformation_operator(&mut self, id: u64) -> OgeomResult<Transform> {
1466        let args = self.args(id, "CARTESIAN_TRANSFORMATION_OPERATOR_3D")?;
1467        let direction_at = |this: &mut Self, i: usize| -> OgeomResult<Option<Vector>> {
1468            match args.get(i).and_then(Arg::reference) {
1469                Some(r) => Ok(Some(this.direction(r)?.vector())),
1470                None => Ok(None),
1471            }
1472        };
1473        let (axis1, axis2, axis3) = (
1474            direction_at(self, 1)?,
1475            direction_at(self, 2)?,
1476            direction_at(self, 5)?,
1477        );
1478        let origin = self.point(args.get(3).and_then(Arg::reference).unwrap_or(0))?;
1479        let scale = args.get(4).and_then(Arg::number).unwrap_or(1.0);
1480        let z = match (axis3, axis1, axis2) {
1481            (Some(z), _, _) => z,
1482            (None, Some(x), Some(y)) => x.cross(y),
1483            _ => Vector::Z,
1484        };
1485        let z = Direction::new(z, self.tol)?.vector();
1486        let x = match axis1 {
1487            Some(x) => x - z * x.dot(z),
1488            None => {
1489                let seed = if z.x.abs() < 0.9 {
1490                    Vector::X
1491                } else {
1492                    Vector::Y
1493                };
1494                seed - z * seed.dot(z)
1495            }
1496        };
1497        let x = Direction::new(x, self.tol)?.vector();
1498        let y = z.cross(x);
1499        let linear = Matrix3::new([[x.x, y.x, z.x], [x.y, y.y, z.y], [x.z, y.z, z.z]]);
1500        Transform::from_parts(linear, scale, origin.to_vector(), self.tol.angular())
1501    }
1502
1503    /// The parameter of a point on one of this kernel's curves.
1504    fn parameter_of(&self, curve: &Curve, p: Point) -> Option<f64> {
1505        crate::inversion::parameter_on(curve, p)
1506    }
1507
1508    /// The pcurves the file states for edge `edge_id` on surface entity
1509    /// `surface_id`: the `PCURVE`s of its `SURFACE_CURVE` or `SEAM_CURVE`
1510    /// drawn on that surface, as written. Nothing where the file states
1511    /// none, or states them in a form not read here.
1512    fn file_pcurves(&mut self, edge_id: u64, surface_id: u64) -> Vec<PlanarCurve> {
1513        let mut out = Vec::new();
1514        let Ok(args) = self.args(edge_id, "EDGE_CURVE") else {
1515            return out;
1516        };
1517        let curve_id = ref_at(args, 3);
1518        let Ok(instance) = self.instance(curve_id) else {
1519            return out;
1520        };
1521        let Some(dressed) = instance
1522            .part("SURFACE_CURVE")
1523            .or_else(|| instance.part("SEAM_CURVE"))
1524            .or_else(|| instance.part("INTERSECTION_CURVE"))
1525        else {
1526            return out;
1527        };
1528        let uses: Vec<u64> = dressed
1529            .get(2)
1530            .and_then(Arg::list)
1531            .unwrap_or(&[])
1532            .iter()
1533            .filter_map(Arg::reference)
1534            .collect();
1535        for id in uses {
1536            let Ok(pargs) = self.args(id, "PCURVE") else {
1537                continue;
1538            };
1539            if pargs.get(1).and_then(Arg::reference) != Some(surface_id) {
1540                continue;
1541            }
1542            let Ok(dargs) = self.args(ref_at(pargs, 2), "DEFINITIONAL_REPRESENTATION") else {
1543                continue;
1544            };
1545            let Some(item) = dargs
1546                .get(1)
1547                .and_then(Arg::list)
1548                .and_then(|items| items.first())
1549                .and_then(Arg::reference)
1550            else {
1551                continue;
1552            };
1553            if let Ok(Some(curve)) = self.nested(|reader| reader.planar_curve(item)) {
1554                out.push(curve);
1555            }
1556        }
1557        // Restated in this kernel's units where the file's are known; a
1558        // pcurve that has no image here at the same parameters stays as
1559        // written.
1560        if out.is_empty() {
1561            return out;
1562        }
1563        if let (Some(chart), Some(pace)) =
1564            (self.chart_units(surface_id), self.curve_units(curve_id))
1565        {
1566            for pcurve in &mut out {
1567                if let Some(restated) =
1568                    in_kernel_units(pcurve, chart, pace, self.angle_scale, self.tol)
1569                {
1570                    *pcurve = restated;
1571                }
1572            }
1573        } else if let (Some(map), Some(pace)) = (
1574            self.swept_line_chart(surface_id),
1575            self.curve_units(curve_id),
1576        ) {
1577            for pcurve in &mut out {
1578                if let Some(restated) = through_map(pcurve, map, pace, self.tol) {
1579                    *pcurve = restated;
1580                }
1581            }
1582        }
1583        out
1584    }
1585
1586    /// How a line swept into a plane reads the file's chart on the plane it
1587    /// is read as: the map from the file's parameters, the line's own and
1588    /// the length along the sweep's vector as ISO 10303-42 parameterizes
1589    /// the extrusion, to this kernel's, the plane's coordinates along its
1590    /// frame. The sweep may lean on the line, so the map is affine, not a
1591    /// stretch along each direction. `None` for any other surface.
1592    fn swept_line_chart(&mut self, id: u64) -> Option<ChartMap> {
1593        let args = self.args(id, "SURFACE_OF_LINEAR_EXTRUSION").ok()?.to_vec();
1594        let Some(SurfaceGeometry::Plane(plane)) = self.surface(id).ok()? else {
1595            return None;
1596        };
1597        let line_id = ref_at(&args, 1);
1598        let Some(Curve::Line(line)) = self.curve(line_id).ok()? else {
1599            return None;
1600        };
1601        let pace = self.curve_units(line_id)?;
1602        let vector = self.args(ref_at(&args, 2), "VECTOR").ok()?.to_vec();
1603        let across = vector.get(2).and_then(Arg::number).unwrap_or(1.0) * self.report.scale_mm;
1604        let sweep = self.direction(vector.get(1)?.reference()?).ok()?.vector();
1605        let frame = plane.plane().frame();
1606        let (x, y) = (frame.x().vector(), frame.y().vector());
1607        let (along, from) = (
1608            line.axis().direction.vector(),
1609            line.axis().location - frame.origin(),
1610        );
1611        Some(ChartMap {
1612            linear: [
1613                [pace * along.dot(x), across * sweep.dot(x)],
1614                [pace * along.dot(y), across * sweep.dot(y)],
1615            ],
1616            offset: (from.dot(x), from.dot(y)),
1617        })
1618    }
1619
1620    /// How a surface's parameters here follow the file's: this kernel's `u`
1621    /// and `v` per file unit of each, as ISO 10303-42 parameterizes the
1622    /// surface. A plane's are lengths; a cylinder's and a cone's `u` is an
1623    /// angle and `v` a length along the axis; a sphere's and a torus's are
1624    /// both angles; a spline's are its own; a revolution's are the angle
1625    /// round its axis and its curve's parameter; an extrusion's are its
1626    /// curve's parameter and a length along its vector; a replica's are its
1627    /// parent's, each that is a length scaled by the replica's scale (its
1628    /// motion a rotation and a shift, never a reflection). `None` for a
1629    /// surface whose chart here is not the file's stretched along each
1630    /// direction (a line swept into a plane, whose sweep may lean on it:
1631    /// [`swept_line_chart`](Self::swept_line_chart) maps its chart).
1632    fn chart_units(&mut self, id: u64) -> Option<(f64, f64)> {
1633        let (keyword, args) = {
1634            let instance = self.instance(id).ok()?;
1635            if [
1636                "B_SPLINE_SURFACE",
1637                "B_SPLINE_SURFACE_WITH_KNOTS",
1638                "BEZIER_SURFACE",
1639                "UNIFORM_SURFACE",
1640                "QUASI_UNIFORM_SURFACE",
1641            ]
1642            .into_iter()
1643            .any(|k| instance.part(k).is_some())
1644            {
1645                return Some((1.0, 1.0));
1646            }
1647            (
1648                instance.keyword(),
1649                instance
1650                    .parts
1651                    .first()
1652                    .map_or(&[][..], |(_, a)| a.as_slice()),
1653            )
1654        };
1655        let (length, angle) = (self.report.scale_mm, self.angle_scale);
1656        match keyword {
1657            "PLANE" => Some((length, length)),
1658            "CYLINDRICAL_SURFACE" | "CONICAL_SURFACE" => Some((angle, length)),
1659            "SPHERICAL_SURFACE" | "TOROIDAL_SURFACE" | "DEGENERATE_TOROIDAL_SURFACE" => {
1660                Some((angle, angle))
1661            }
1662            "OFFSET_SURFACE" | "RECTANGULAR_TRIMMED_SURFACE" | "CURVE_BOUNDED_SURFACE" => {
1663                self.chart_units(ref_at(args, 1))
1664            }
1665            "SURFACE_OF_REVOLUTION" => Some((angle, self.curve_units(ref_at(args, 1))?)),
1666            // A replica's parameters are its parent's; carried by a motion
1667            // and a scaling, each of them that is a length here scales.
1668            "SURFACE_REPLICA" => {
1669                let parent = ref_at(args, 1);
1670                let scale = self
1671                    .args(ref_at(args, 2), "CARTESIAN_TRANSFORMATION_OPERATOR_3D")
1672                    .ok()?
1673                    .get(4)
1674                    .and_then(Arg::number)
1675                    .unwrap_or(1.0);
1676                if !(scale > 0.0 && scale.is_finite()) {
1677                    return None;
1678                }
1679                let (u, v) = self.chart_units(parent)?;
1680                let (lu, lv) = self.length_parameters(parent)?;
1681                Some((
1682                    if lu { u * scale } else { u },
1683                    if lv { v * scale } else { v },
1684                ))
1685            }
1686            "SURFACE_OF_LINEAR_EXTRUSION" => {
1687                let along = self.curve_units(ref_at(args, 1))?;
1688                let vector = self.args(ref_at(args, 2), "VECTOR").ok()?;
1689                let magnitude = vector.get(2).and_then(Arg::number).unwrap_or(1.0);
1690                let direction = self.direction(vector[1].reference()?).ok()?;
1691                let across = magnitude * length;
1692                // The reader takes a circle swept along its axis for the
1693                // cylinder it is, whose heights run along the circle's
1694                // axis, with the sweep or against it.
1695                match self.surface(id).ok()?? {
1696                    SurfaceGeometry::Cylinder(c) => {
1697                        let z = c.cylinder().frame().z();
1698                        Some((along, across.copysign(direction.vector().dot(z.vector()))))
1699                    }
1700                    SurfaceGeometry::Extrusion(_) => Some((along, across)),
1701                    _ => None,
1702                }
1703            }
1704            _ => None,
1705        }
1706    }
1707
1708    /// Whether each of a surface's parameters here is a length, which a
1709    /// scaling of the surface scales: a plane's both, a cylinder's and a
1710    /// cone's height, a revolution's curve parameter and an extrusion's
1711    /// where it is the length along a line or a parabola's axis normal, an
1712    /// extrusion's sweep; an angle and a spline's own parameter are not.
1713    /// `None` for a surface [`chart_units`](Self::chart_units) does not
1714    /// read.
1715    fn length_parameters(&mut self, id: u64) -> Option<(bool, bool)> {
1716        let (keyword, args) = {
1717            let instance = self.instance(id).ok()?;
1718            if [
1719                "B_SPLINE_SURFACE",
1720                "B_SPLINE_SURFACE_WITH_KNOTS",
1721                "BEZIER_SURFACE",
1722                "UNIFORM_SURFACE",
1723                "QUASI_UNIFORM_SURFACE",
1724            ]
1725            .into_iter()
1726            .any(|k| instance.part(k).is_some())
1727            {
1728                return Some((false, false));
1729            }
1730            (
1731                instance.keyword().to_owned(),
1732                instance
1733                    .parts
1734                    .first()
1735                    .map_or_else(Vec::new, |(_, a)| a.clone()),
1736            )
1737        };
1738        match keyword.as_str() {
1739            "PLANE" => Some((true, true)),
1740            "CYLINDRICAL_SURFACE" | "CONICAL_SURFACE" => Some((false, true)),
1741            "SPHERICAL_SURFACE" | "TOROIDAL_SURFACE" | "DEGENERATE_TOROIDAL_SURFACE" => {
1742                Some((false, false))
1743            }
1744            "OFFSET_SURFACE"
1745            | "RECTANGULAR_TRIMMED_SURFACE"
1746            | "CURVE_BOUNDED_SURFACE"
1747            | "SURFACE_REPLICA" => self.length_parameters(ref_at(&args, 1)),
1748            "SURFACE_OF_REVOLUTION" => Some((false, self.length_parameter(ref_at(&args, 1))?)),
1749            "SURFACE_OF_LINEAR_EXTRUSION" => match self.surface(id).ok()?? {
1750                SurfaceGeometry::Cylinder(_) => Some((false, true)),
1751                SurfaceGeometry::Extrusion(_) => {
1752                    Some((self.length_parameter(ref_at(&args, 1))?, true))
1753                }
1754                _ => None,
1755            },
1756            _ => None,
1757        }
1758    }
1759
1760    /// Whether a curve's parameter here is a length, which a scaling of the
1761    /// curve scales: a line's and a parabola's; a conic's angle, a
1762    /// hyperbola's and a spline's own parameter are not. `None` for a curve
1763    /// [`curve_units`](Self::curve_units) does not read.
1764    fn length_parameter(&mut self, id: u64) -> Option<bool> {
1765        let (keyword, inner) = {
1766            let instance = self.instance(id).ok()?;
1767            if [
1768                "B_SPLINE_CURVE",
1769                "B_SPLINE_CURVE_WITH_KNOTS",
1770                "BEZIER_CURVE",
1771                "UNIFORM_CURVE",
1772                "QUASI_UNIFORM_CURVE",
1773            ]
1774            .into_iter()
1775            .any(|k| instance.part(k).is_some())
1776            {
1777                return Some(false);
1778            }
1779            let args = instance
1780                .parts
1781                .first()
1782                .map_or(&[][..], |(_, a)| a.as_slice());
1783            (instance.keyword().to_owned(), ref_at(args, 1))
1784        };
1785        match keyword.as_str() {
1786            "LINE" | "PARABOLA" => Some(true),
1787            "CIRCLE" | "ELLIPSE" | "HYPERBOLA" => Some(false),
1788            "SURFACE_CURVE" | "SEAM_CURVE" | "INTERSECTION_CURVE" | "TRIMMED_CURVE"
1789            | "OFFSET_CURVE_3D" => self.length_parameter(inner),
1790            _ => None,
1791        }
1792    }
1793
1794    /// How a curve's parameter here follows the file's: this kernel's per
1795    /// file unit, as ISO 10303-42 parameterizes the curve. A line's runs
1796    /// by its vector, a length; a circle's and an ellipse's are angles; a
1797    /// hyperbola's and a spline's are their own; a parabola's here is the
1798    /// distance along its axis's normal, twice its focal length per file
1799    /// unit. `None` for a curve not read that way.
1800    fn curve_units(&mut self, id: u64) -> Option<f64> {
1801        let (keyword, args) = {
1802            let instance = self.instance(id).ok()?;
1803            if [
1804                "B_SPLINE_CURVE",
1805                "B_SPLINE_CURVE_WITH_KNOTS",
1806                "BEZIER_CURVE",
1807                "UNIFORM_CURVE",
1808                "QUASI_UNIFORM_CURVE",
1809            ]
1810            .into_iter()
1811            .any(|k| instance.part(k).is_some())
1812            {
1813                return Some(1.0);
1814            }
1815            (
1816                instance.keyword(),
1817                instance
1818                    .parts
1819                    .first()
1820                    .map_or(&[][..], |(_, a)| a.as_slice()),
1821            )
1822        };
1823        let length = self.report.scale_mm;
1824        match keyword {
1825            "LINE" => {
1826                let vector = self.args(ref_at(args, 2), "VECTOR").ok()?;
1827                Some(vector.get(2).and_then(Arg::number).unwrap_or(1.0) * length)
1828            }
1829            "CIRCLE" | "ELLIPSE" => Some(self.angle_scale),
1830            "HYPERBOLA" => Some(1.0),
1831            "PARABOLA" => Some(2.0 * args.get(2).and_then(Arg::number)? * length),
1832            "SURFACE_CURVE" | "SEAM_CURVE" | "INTERSECTION_CURVE" | "TRIMMED_CURVE"
1833            | "OFFSET_CURVE_3D" => self.curve_units(ref_at(args, 1)),
1834            _ => None,
1835        }
1836    }
1837
1838    /// A curve in a surface's parameters, its coordinates as written: a
1839    /// line, a circle, an ellipse, a spline, or a trim of one, which is its
1840    /// basis.
1841    fn planar_curve(&mut self, id: u64) -> OgeomResult<Option<PlanarCurve>> {
1842        let instance = self.instance(id)?;
1843        let (base, knotted, weights) = (
1844            instance.part("B_SPLINE_CURVE"),
1845            instance.part("B_SPLINE_CURVE_WITH_KNOTS"),
1846            instance.part("RATIONAL_B_SPLINE_CURVE"),
1847        );
1848        // A spline's degree, control points and knots sit at these places
1849        // in a simple instance, and split between parts in a complex one.
1850        let spline = match (base, knotted) {
1851            (Some(base), Some(knotted)) => {
1852                Some((base.first(), base.get(1), knotted.first(), knotted.get(1)))
1853            }
1854            (None, Some(knotted)) => Some((
1855                knotted.get(1),
1856                knotted.get(2),
1857                knotted.get(6),
1858                knotted.get(7),
1859            )),
1860            _ => None,
1861        };
1862        if let Some((degree, control, mults, knots)) = spline {
1863            let degree = file_degree(degree, 0.0)?;
1864            let mut points = Vec::new();
1865            for r in control
1866                .and_then(Arg::list)
1867                .unwrap_or(&[])
1868                .iter()
1869                .filter_map(Arg::reference)
1870            {
1871                points.push(self.point2(r)?);
1872            }
1873            let expanded = expanded_knots(
1874                mults.and_then(Arg::list).unwrap_or(&[]),
1875                knots.and_then(Arg::list).unwrap_or(&[]),
1876            )?;
1877            let flat: Vec<f64> = weights
1878                .and_then(|w| w.first())
1879                .and_then(Arg::list)
1880                .unwrap_or(&[])
1881                .iter()
1882                .filter_map(Arg::number)
1883                .collect();
1884            let control = points
1885                .into_iter()
1886                .zip(flat.iter().chain(std::iter::repeat(&1.0)))
1887                .map(|(p, w)| Weighted::new(p, *w, self.tol))
1888                .collect::<OgeomResult<Vec<_>>>()?;
1889            let knots = KnotVector::new(expanded, degree)?;
1890            return Ok(Some(
1891                ogeom_geom::BSpline2d::rational(knots, control)?.into(),
1892            ));
1893        }
1894        let args = instance
1895            .parts
1896            .first()
1897            .map_or(&[][..], |(_, a)| a.as_slice());
1898        let out: PlanarCurve = match instance.keyword() {
1899            "LINE" => {
1900                let through = self.point2(ref_at(args, 1))?;
1901                let vector = self.args(ref_at(args, 2), "VECTOR")?;
1902                let direction = self.direction2(vector[1].reference().unwrap_or(0))?;
1903                // The vector's magnitude is the parameter's pace along the
1904                // line. A unit one is the length a line here runs by; any
1905                // other is the straight spline of degree one at that pace.
1906                let magnitude = vector.get(2).and_then(Arg::number).unwrap_or(1.0);
1907                if magnitude == 1.0 {
1908                    let axis = ogeom_math::Axis2 {
1909                        location: through,
1910                        direction,
1911                    };
1912                    ogeom_geom::Line2d::over(axis, -SURFACE_EXTENT, SURFACE_EXTENT)?.into()
1913                } else {
1914                    let step = direction.vector() * magnitude;
1915                    let ends = vec![
1916                        through - step * SURFACE_EXTENT,
1917                        through + step * SURFACE_EXTENT,
1918                    ];
1919                    let knots = vec![
1920                        -SURFACE_EXTENT,
1921                        -SURFACE_EXTENT,
1922                        SURFACE_EXTENT,
1923                        SURFACE_EXTENT,
1924                    ];
1925                    ogeom_geom::BSpline2d::new(KnotVector::new(knots, 1)?, ends, self.tol)?.into()
1926                }
1927            }
1928            "CIRCLE" => {
1929                let frame = self.frame2(ref_at(args, 1))?;
1930                let radius = args.get(2).and_then(Arg::number).unwrap_or(0.0);
1931                ogeom_geom::Circle2d::new(ogeom_math::Circle2::new(frame, radius, self.tol)?).into()
1932            }
1933            "ELLIPSE" => {
1934                let frame = self.frame2(ref_at(args, 1))?;
1935                let a = args.get(2).and_then(Arg::number).unwrap_or(0.0);
1936                let b = args.get(3).and_then(Arg::number).unwrap_or(0.0);
1937                ogeom_geom::Ellipse2d::new(ogeom_math::Ellipse2::new(frame, a, b, self.tol)?).into()
1938            }
1939            "TRIMMED_CURVE" => {
1940                // The trims restate the edge's ends; the basis carries the
1941                // parameter, so it is the pcurve.
1942                return self.planar_curve(ref_at(args, 1));
1943            }
1944            _ => return Ok(None),
1945        };
1946        Ok(Some(out))
1947    }
1948
1949    /// A point of a surface's parameters, as written.
1950    fn point2(&mut self, id: u64) -> OgeomResult<ogeom_math::Point2> {
1951        let args = self.args(id, "CARTESIAN_POINT")?;
1952        let Some(coords) = args.get(1).and_then(Arg::list) else {
1953            ogeom_bail!(Construction, "#{id}: a point without coordinates");
1954        };
1955        let value = |i: usize| coords.get(i).and_then(Arg::number).unwrap_or(0.0);
1956        Ok(ogeom_math::Point2::new(value(0), value(1)))
1957    }
1958
1959    fn direction2(&mut self, id: u64) -> OgeomResult<ogeom_math::Direction2> {
1960        let args = self.args(id, "DIRECTION")?;
1961        let Some(coords) = args.get(1).and_then(Arg::list) else {
1962            ogeom_bail!(Construction, "#{id}: a direction without components");
1963        };
1964        let value = |i: usize| coords.get(i).and_then(Arg::number).unwrap_or(0.0);
1965        ogeom_math::Direction2::new(ogeom_math::Vector2::new(value(0), value(1)), self.tol)
1966    }
1967
1968    /// An `AXIS2_PLACEMENT_2D` as a frame, its reference direction the
1969    /// standard's default where the file leaves it out.
1970    fn frame2(&mut self, id: u64) -> OgeomResult<ogeom_math::Frame2> {
1971        let args = self.args(id, "AXIS2_PLACEMENT_2D")?;
1972        let origin = self.point2(ref_at(args, 1))?;
1973        let x = match args.get(2).and_then(Arg::reference) {
1974            Some(r) => self.direction2(r)?,
1975            None => ogeom_math::Direction2::X,
1976        };
1977        Ok(ogeom_math::Frame2::new(origin, x))
1978    }
1979
1980    // --- topology ------------------------------------------------------------
1981
1982    fn vertex(&mut self, id: u64) -> OgeomResult<Shape> {
1983        if let Some(shape) = self.vertices.get(&id) {
1984            return Ok(shape.clone());
1985        }
1986        let args = self.args(id, "VERTEX_POINT")?;
1987        let point = self.point(ref_at(args, 1))?;
1988        let shape = make_vertex(&mut self.model, point).shape;
1989        self.vertices.insert(id, shape.clone());
1990        Ok(shape)
1991    }
1992
1993    /// An `EDGE_CURVE`, built once, curve-forward.
1994    ///
1995    /// Returns the edge, its curve, its range, and whether STEP's edge
1996    /// direction runs *against* the curve, which each use folds into its
1997    /// own orientation.
1998    fn edge(&mut self, id: u64) -> OgeomResult<Option<BuiltEdge>> {
1999        if let Some(found) = self.edges.get(&id) {
2000            return Ok(Some(found.clone()));
2001        }
2002        let args = self.args(id, "EDGE_CURVE")?;
2003        let v1 = ref_at(args, 1);
2004        let v2 = ref_at(args, 2);
2005        let Some(mut curve) = self.curve(ref_at(args, 3))? else {
2006            return Ok(None);
2007        };
2008        let same_sense = !args.get(4).is_some_and(|a| a.is_enum("F"));
2009
2010        let p1 = {
2011            let vargs = self.args(v1, "VERTEX_POINT")?;
2012            self.point(ref_at(vargs, 1))?
2013        };
2014        let p2 = {
2015            let vargs = self.args(v2, "VERTEX_POINT")?;
2016            self.point(ref_at(vargs, 1))?
2017        };
2018
2019        // The edge is built along the curve's own parameter; a STEP edge
2020        // running the other way is flagged, and every use composes the flag
2021        // into its orientation.
2022        let (start, end, mut flipped) = if same_sense {
2023            (p1, p2, false)
2024        } else {
2025            (p2, p1, true)
2026        };
2027        let (range, closed) = match (
2028            self.parameter_of(&curve, start),
2029            self.parameter_of(&curve, end),
2030        ) {
2031            (Some(a), Some(b)) => {
2032                let period = if curve.is_periodic() {
2033                    let (lo, hi) = curve.domain();
2034                    hi - lo
2035                } else {
2036                    0.0
2037                };
2038                if v1 == v2 {
2039                    ((a, a + if period > 0.0 { period } else { 0.0 }), true)
2040                } else if period > 0.0 && b <= a + self.tol.parametric() {
2041                    ((a, b + period), false)
2042                } else if b < a - self.tol.parametric() {
2043                    // The vertices stand at descending parameters on an
2044                    // open curve whatever the sense flag says: a
2045                    // mesh-to-STEP converter writes every edge forward and
2046                    // lets the line run the other way. The edge is built
2047                    // along the curve's own parameter and runs against it.
2048                    flipped = !flipped;
2049                    self.report.warnings.push(format!(
2050                        "#{id}: the edge's vertices run against its curve's \
2051                         parameter despite its sense flag; the edge was reversed"
2052                    ));
2053                    self.tally("edge-against-curve", a - b, id);
2054                    ((b, a), false)
2055                } else {
2056                    ((a, b), false)
2057                }
2058            }
2059            _ => {
2060                // No closed-form inversion: take the curve's own domain and
2061                // hold the endpoints to it.
2062                //
2063                // A closed edge whose one vertex sits on the curve but away
2064                // from its seam (a fitted loop written with its start
2065                // wherever the fit began, the vertex millimetres along it)
2066                // would otherwise be held to a seam the vertex misses by
2067                // that much, and the vertex's tolerance widened to say so.
2068                // The seam is moved to the vertex instead: the same curve,
2069                // begun where the edge does.
2070                if v1 == v2
2071                    && let Curve::BSpline(spline) = &curve
2072                {
2073                    let (lo, hi) = curve.domain();
2074                    let gap = curve.point_at(lo, self.tol)?.distance(start);
2075                    if gap > self.tol.confusion() * 10.0 {
2076                        let samples = (spline.control_points().len() * 8).max(64);
2077                        if let Ok(found) = project_on_curve(&curve, start, samples, self.tol)
2078                            && found.distance <= self.tol.confusion() * 1e4
2079                            && found.parameter > lo + self.tol.parametric()
2080                            && found.parameter < hi - self.tol.parametric()
2081                            && let Ok(moved) = spline.reseamed_at(found.parameter, self.tol)
2082                        {
2083                            curve = Curve::BSpline(moved);
2084                            self.report.warnings.push(format!(
2085                                "#{id}: a closed edge's vertex sits {gap:.2e} along its \
2086                                 curve from the seam; the seam was moved to the vertex"
2087                            ));
2088                            self.tally("reseamed", gap, id);
2089                        }
2090                    }
2091                }
2092                let (lo, hi) = curve.domain();
2093                let head = curve.point_at(lo, self.tol)?;
2094                let tail = curve.point_at(hi, self.tol)?;
2095                let (head_miss, tail_miss) = (head.distance(start), tail.distance(end));
2096                if head_miss > self.tol.confusion() * 10.0
2097                    || tail_miss > self.tol.confusion() * 10.0
2098                {
2099                    // An open edge whose vertices stand *on* the curve but
2100                    // short of its ends (a file that writes the whole
2101                    // spline and lets the vertices say where the edge
2102                    // stops, millimetres in) takes the window between the
2103                    // vertices' own feet. Held to the whole curve, the edge
2104                    // overshoots its neighbours and the face it bounds draws
2105                    // as nothing at all.
2106                    let window = if v1 == v2 {
2107                        None
2108                    } else {
2109                        window_between_feet(&curve, start, end, self.tol)
2110                    };
2111                    if let Some(window) = window {
2112                        self.report.warnings.push(format!(
2113                            "#{id}: edge endpoints sit {head_miss:.2e} and {tail_miss:.2e} \
2114                             from its curve's ends; the window between the vertices' \
2115                             feet on the curve was taken"
2116                        ));
2117                        self.tally("vertex-window", head_miss.max(tail_miss), id);
2118                        (window, false)
2119                    } else {
2120                        self.report.warnings.push(format!(
2121                            "#{id}: edge endpoints sit {head_miss:.2e} and {tail_miss:.2e} \
2122                             from its curve's ends; the curve's own domain was taken"
2123                        ));
2124                        ((lo, hi), v1 == v2)
2125                    }
2126                } else {
2127                    ((lo, hi), v1 == v2)
2128                }
2129            }
2130        };
2131        let _ = closed;
2132
2133        let (vlo, vhi) = if flipped {
2134            (self.vertex(v2)?, self.vertex(v1)?)
2135        } else {
2136            (self.vertex(v1)?, self.vertex(v2)?)
2137        };
2138        // Real files are imprecise. Where the curve's end misses its vertex
2139        // by more than the default
2140        // tolerance, the vertex's tolerance *grows* to state the gap; the
2141        // data model's growing tolerances are exactly for this, and the
2142        // warning keeps the healing visible.
2143        for (vertex, t) in [(&vlo, range.0), (&vhi, range.1)] {
2144            let end = curve.point_at(t, self.tol)?;
2145            let stated = {
2146                let Some(node) = self.model.node(vertex) else {
2147                    continue;
2148                };
2149                let Some(data) = node.data().as_vertex() else {
2150                    continue;
2151                };
2152                (data.point, data.tolerance)
2153            };
2154            let gap = end.distance(stated.0);
2155            if gap > stated.1.get() {
2156                if let Some(node) = self.model.node_mut(vertex)
2157                    && let ogeom_topo::NodeData::Vertex(data) = node.data_mut()
2158                {
2159                    data.tolerance = data.tolerance.widen_to(gap + self.tol.confusion());
2160                }
2161                self.warn_vertex_miss(id, gap);
2162            }
2163        }
2164        let shape =
2165            make_edge_between(&mut self.model, curve.clone(), range, &vlo, &vhi, self.tol)?.shape;
2166        let entry = (shape, curve, range, flipped);
2167        self.edges.insert(id, entry.clone());
2168        Ok(Some(entry))
2169    }
2170
2171    /// The edge a bound names as the bound walks it: its pieces where it
2172    /// was cut at a pole, in order along its curve, or the edge itself
2173    /// under its own id.
2174    fn edge_pieces(&mut self, id: u64) -> OgeomResult<Option<Vec<(u64, BuiltEdge)>>> {
2175        if let Some(pieces) = self.pieces.get(&id) {
2176            return Ok(Some(pieces.clone()));
2177        }
2178        Ok(self.edge(id)?.map(|built| vec![(id, built)]))
2179    }
2180
2181    /// The keys a bound's use of edge `id` stands under.
2182    fn piece_keys(&self, id: u64) -> Vec<u64> {
2183        self.pieces.get(&id).map_or_else(
2184            || vec![id],
2185            |pieces| pieces.iter().map(|(k, _)| *k).collect(),
2186        )
2187    }
2188
2189    /// What a file's edge id stands for in the read model: the pieces it
2190    /// was cut into at a pole, in order along its curve, or the edge
2191    /// itself; nothing when it never built. Everything that names an edge
2192    /// by id (a style, a shape aspect) resolves through here, so it lands
2193    /// on the edges the faces use.
2194    fn edge_shapes(&self, id: u64) -> Vec<Shape> {
2195        if let Some(pieces) = self.pieces.get(&id) {
2196            return pieces
2197                .iter()
2198                .map(|(_, (piece, ..))| piece.clone())
2199                .collect();
2200        }
2201        self.edges
2202            .get(&id)
2203            .map(|(shape, ..)| shape.clone())
2204            .into_iter()
2205            .collect()
2206    }
2207
2208    /// Cut every edge of these faces whose curve runs through a pole of a
2209    /// face it bounds (a sphere's pole, a cone's apex, a patch's collapsed
2210    /// side) at that pole, before any face is built.
2211    ///
2212    /// No single image of such a curve follows it through the chart: at the
2213    /// pole it leaves along another column than the one it arrived on, and
2214    /// a fit across the jump stands millimetres off the edge. Cut there,
2215    /// each piece has an image of its own (a meridian of a sphere is
2216    /// exactly a straight column), and the face's wire closes across the
2217    /// pole row as it does at any pole vertex. The cut is made on the edge,
2218    /// so every face it bounds walks the same pieces.
2219    ///
2220    /// Best-effort: anything unreadable here is left whole, and the face
2221    /// that uses it reports it as it would have.
2222    fn cut_at_poles(&mut self, face_ids: &[u64]) {
2223        let mut crossings: BTreeMap<u64, Vec<crate::pcurves::Crossing>> = BTreeMap::new();
2224        for &fid in face_ids {
2225            let Ok(args) = self.face_args(fid) else {
2226                continue;
2227            };
2228            let Some(surface) = args
2229                .get(2)
2230                .and_then(Arg::reference)
2231                .and_then(|sid| self.surface(sid).ok().flatten())
2232            else {
2233                continue;
2234            };
2235            let poles = crate::pcurves::chart_poles(&surface, self.tol);
2236            if poles.is_empty() {
2237                continue;
2238            }
2239            let bounds: Vec<u64> = args
2240                .get(1)
2241                .and_then(Arg::list)
2242                .unwrap_or(&[])
2243                .iter()
2244                .filter_map(Arg::reference)
2245                .collect();
2246            for bound in bounds {
2247                let Ok((loop_id, _)) = self.bound_args(bound) else {
2248                    continue;
2249                };
2250                let Ok(loop_args) = self.args(loop_id, "EDGE_LOOP") else {
2251                    continue;
2252                };
2253                let edge_ids: Vec<u64> = loop_args
2254                    .get(1)
2255                    .and_then(Arg::list)
2256                    .unwrap_or(&[])
2257                    .iter()
2258                    .filter_map(Arg::reference)
2259                    .filter_map(|oe| {
2260                        self.args(oe, "ORIENTED_EDGE")
2261                            .ok()
2262                            .and_then(|a| a.get(3).and_then(Arg::reference))
2263                    })
2264                    .collect();
2265                for edge_id in edge_ids {
2266                    if self.pieces.contains_key(&edge_id) {
2267                        continue;
2268                    }
2269                    let Ok(Some((_, curve, range, _))) = self.edge(edge_id) else {
2270                        continue;
2271                    };
2272                    let found = crate::pcurves::pole_crossings(&curve, range, &poles, self.tol);
2273                    if found.is_empty() {
2274                        continue;
2275                    }
2276                    let entry = crossings.entry(edge_id).or_default();
2277                    for crossing in found {
2278                        if entry.iter().all(|(t, ..)| (t - crossing.0).abs() > 1e-9) {
2279                            entry.push(crossing);
2280                        }
2281                    }
2282                }
2283            }
2284        }
2285        for (edge_id, mut at) in crossings {
2286            at.sort_by(|x, y| x.0.total_cmp(&y.0));
2287            let Some((shape, curve, range, flipped)) = self.edges.get(&edge_id).cloned() else {
2288                continue;
2289            };
2290            let Ok(cut) = crate::pcurves::split_at_poles(
2291                &mut self.model,
2292                &shape,
2293                &curve,
2294                range,
2295                &at,
2296                &mut self.pole_vertices,
2297                self.tol,
2298            ) else {
2299                continue;
2300            };
2301            let pieces = cut
2302                .into_iter()
2303                .map(|(piece, window)| {
2304                    self.piece_key -= 1;
2305                    (self.piece_key, (piece, curve.clone(), window, flipped))
2306                })
2307                .collect();
2308            self.pieces.insert(edge_id, pieces);
2309        }
2310    }
2311
2312    /// Split one rim of every band whose two rims start on different
2313    /// columns, before any face is built.
2314    ///
2315    /// A face on a periodic surface bounded by two whole circles that are
2316    /// parallels of it needs a seam joining a vertex of one rim to a vertex
2317    /// of the other along one column. Where the rims' vertices stand on
2318    /// different columns, the second rim (or the first, where the second
2319    /// is already in pieces) is split on the other's column. The cut is
2320    /// made on the edge, so every face it bounds walks both pieces, and the
2321    /// band is then built through the common column.
2322    ///
2323    /// A split rim changes every face that holds it, so the faces are
2324    /// passed over again until a pass splits and marks nothing: a band on
2325    /// the far side of the rim, seen whole before the split, is marked as a
2326    /// band of rings then, or it would be built with a rim in pieces and no
2327    /// seam.
2328    ///
2329    /// Best-effort: a face whose rims resist is left as it was, and the
2330    /// face reports it as it would have.
2331    fn split_band_rims(&mut self, face_ids: &[u64]) {
2332        loop {
2333            let before = (self.pieces.len(), self.band_faces.len());
2334            for &fid in face_ids {
2335                let _ = self.split_band_rim(fid);
2336            }
2337            if (self.pieces.len(), self.band_faces.len()) == before {
2338                break;
2339            }
2340        }
2341    }
2342
2343    /// [`Self::split_band_rims`] for one face.
2344    fn split_band_rim(&mut self, fid: u64) -> OgeomResult<()> {
2345        let args = self.face_args(fid)?.to_vec();
2346        let Some(surface) = args
2347            .get(2)
2348            .and_then(Arg::reference)
2349            .and_then(|sid| self.surface(sid).ok().flatten())
2350        else {
2351            return Ok(());
2352        };
2353        if !surface.is_periodic_u() {
2354            return Ok(());
2355        }
2356        let bounds: Vec<u64> = args
2357            .get(1)
2358            .and_then(Arg::list)
2359            .unwrap_or(&[])
2360            .iter()
2361            .filter_map(Arg::reference)
2362            .collect();
2363        let [first, second] = bounds[..] else {
2364            return Ok(());
2365        };
2366        // Each bound one oriented edge: the rim's file id.
2367        let mut rims = Vec::with_capacity(2);
2368        for bound in [first, second] {
2369            let (loop_id, _) = self.bound_args(bound)?;
2370            let Ok(loop_args) = self.args(loop_id, "EDGE_LOOP") else {
2371                return Ok(());
2372            };
2373            let edge_ids: Vec<u64> = loop_args
2374                .get(1)
2375                .and_then(Arg::list)
2376                .unwrap_or(&[])
2377                .iter()
2378                .filter_map(Arg::reference)
2379                .filter_map(|oe| {
2380                    self.args(oe, "ORIENTED_EDGE")
2381                        .ok()
2382                        .and_then(|a| a.get(3).and_then(Arg::reference))
2383                })
2384                .collect();
2385            let [edge_id] = edge_ids[..] else {
2386                return Ok(());
2387            };
2388            rims.push(edge_id);
2389        }
2390        // Each rim's edges as the faces will walk them, and its circle.
2391        let mut rings: Vec<(Vec<Shape>, ogeom_math::Circle)> = Vec::with_capacity(2);
2392        for &edge_id in &rims {
2393            let Some(pieces) = self.edge_pieces(edge_id)? else {
2394                return Ok(());
2395            };
2396            let Curve::Circle(c) = &pieces[0].1.1 else {
2397                return Ok(());
2398            };
2399            let circle = c.circle();
2400            let shapes: Vec<Shape> = pieces.into_iter().map(|(_, (s, ..))| s).collect();
2401            if shapes.len() == 1 {
2402                let Some((a, b)) = ogeom_algo::edge_vertices(&self.model, &shapes[0])? else {
2403                    return Ok(());
2404                };
2405                if !a.is_same(&b) {
2406                    return Ok(());
2407                }
2408            }
2409            rings.push((shapes, circle));
2410        }
2411        let all: Vec<&Shape> = rings.iter().flat_map(|(s, _)| s.iter()).collect();
2412        if !ogeom_algo::rings_are_parallels(&self.model, &surface, &all, self.tol)? {
2413            return Ok(());
2414        }
2415        let starts = |model: &Model, ring: &[Shape]| -> OgeomResult<Vec<(Shape, Point)>> {
2416            let mut out = Vec::with_capacity(ring.len());
2417            for edge in ring {
2418                if let Some((v, _)) = ogeom_algo::edge_vertices(model, edge)?
2419                    && let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex())
2420                {
2421                    let at = data.point;
2422                    out.push((v, at));
2423                }
2424            }
2425            Ok(out)
2426        };
2427        let starts_a = starts(&self.model, &rings[0].0)?;
2428        let starts_b = starts(&self.model, &rings[1].0)?;
2429        let shared = starts_a.iter().any(|(_, p)| {
2430            starts_b
2431                .iter()
2432                .any(|(_, q)| same_column(&rings[1].1, *p, *q, self.tol))
2433        });
2434        let split_any = rings.iter().any(|(s, _)| s.len() > 1);
2435        if shared {
2436            if split_any {
2437                self.band_faces.insert(fid);
2438            }
2439            return Ok(());
2440        }
2441        // Split a rim still whole on the other's first vertex column.
2442        let (target, anchor) = if rings[1].0.len() == 1 {
2443            (1, &starts_a[0].0)
2444        } else if rings[0].0.len() == 1 {
2445            (0, &starts_b[0].0)
2446        } else {
2447            return Ok(());
2448        };
2449        let edge_id = rims[target];
2450        let Some((shape, curve, _, flipped)) = self.edges.get(&edge_id).cloned() else {
2451            return Ok(());
2452        };
2453        let Some(split) =
2454            ogeom_algo::split_ring_on_column(&mut self.model, &surface, &shape, anchor, self.tol)?
2455        else {
2456            return Ok(());
2457        };
2458        let mut pieces = Vec::with_capacity(2);
2459        for piece in split.history.modified(&shape) {
2460            let Some(ogeom_topo::EdgeRepr::Curve3d { range, .. }) = self
2461                .model
2462                .node(piece)
2463                .and_then(|n| n.data().as_edge())
2464                .and_then(|d| d.curve3d())
2465            else {
2466                return Ok(());
2467            };
2468            self.piece_key -= 1;
2469            pieces.push((
2470                self.piece_key,
2471                (piece.clone(), curve.clone(), *range, flipped),
2472            ));
2473        }
2474        self.pieces.insert(edge_id, pieces);
2475        self.band_faces.insert(fid);
2476        Ok(())
2477    }
2478
2479    /// A band face whose rim was split on the other rim's column: the band
2480    /// of rings through a column both rims have a vertex on. Each ring's
2481    /// occurrences are turned to start there and keep the direction the
2482    /// file walks them, which says which side the face faces.
2483    fn band_of_split_rims(
2484        &mut self,
2485        surface: &SurfaceGeometry,
2486        wires: &[Shape],
2487    ) -> OgeomResult<Shape> {
2488        let [wire_a, wire_b] = wires else {
2489            ogeom_bail!(Construction, "a band has two rims");
2490        };
2491        let ring_a = self.model.ordered_children_of(wire_a)?;
2492        let ring_b = self.model.ordered_children_of(wire_b)?;
2493        let circle = {
2494            let Some(ogeom_topo::EdgeRepr::Curve3d { curve, .. }) = self
2495                .model
2496                .node(&ring_b[0])
2497                .and_then(|n| n.data().as_edge())
2498                .and_then(|d| d.curve3d())
2499            else {
2500                ogeom_bail!(Construction, "a band rim has no curve");
2501            };
2502            let Some(Curve::Circle(c)) = self.model.geometry().curve(*curve) else {
2503                ogeom_bail!(Construction, "a band rim is not a circle");
2504            };
2505            c.circle()
2506        };
2507        let start = |model: &Model, edge: &Shape| -> OgeomResult<Point> {
2508            let Some((v, _)) = ogeom_algo::edge_vertices(model, edge)? else {
2509                ogeom_bail!(Construction, "a band rim has no vertex");
2510            };
2511            let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex()) else {
2512                ogeom_bail!(Construction, "a band rim's vertex holds no point");
2513            };
2514            Ok(data.point)
2515        };
2516        for i in 0..ring_a.len() {
2517            let p = start(&self.model, &ring_a[i])?;
2518            for j in 0..ring_b.len() {
2519                let q = start(&self.model, &ring_b[j])?;
2520                if !same_column(&circle, p, q, self.tol) {
2521                    continue;
2522                }
2523                let mut a = ring_a.clone();
2524                a.rotate_left(i);
2525                let mut b = ring_b.clone();
2526                b.rotate_left(j);
2527                return ogeom_algo::make_band_of_rings(&mut self.model, surface, &a, &b, self.tol);
2528            }
2529        }
2530        ogeom_bail!(Construction, "the band's rims share no vertex column")
2531    }
2532
2533    fn face(&mut self, id: u64) -> OgeomResult<Option<Shape>> {
2534        if let Some(shape) = self.faces.get(&id) {
2535            return Ok(Some(shape.clone()));
2536        }
2537        let args = self.face_args(id)?;
2538        let bounds: Vec<u64> = args
2539            .get(1)
2540            .and_then(Arg::list)
2541            .unwrap_or(&[])
2542            .iter()
2543            .filter_map(Arg::reference)
2544            .collect();
2545        let Some(surface) = self.surface(ref_at(args, 2))? else {
2546            return Ok(None);
2547        };
2548        let face_forward = !args.get(3).is_some_and(|a| a.is_enum("F"));
2549        let surface_id = self.model.geometry_mut().add_surface(surface.clone());
2550
2551        // Outer bound first, so the face's first wire is its outer ring.
2552        let mut ordered = bounds.clone();
2553        ordered.sort_by_key(|b| {
2554            self.instances
2555                .get(*b)
2556                .map_or(1, |i| i32::from(i.part("FACE_OUTER_BOUND").is_none()))
2557        });
2558
2559        // Which edges this face uses twice: those are seams, and get both
2560        // sides' pcurves.
2561        let mut edge_uses: FastMap<u64, usize> = FastMap::default();
2562        for &bound in &ordered {
2563            let bargs = self.bound_args(bound)?;
2564            if self.instance(bargs.0)?.part("VERTEX_LOOP").is_some() {
2565                continue;
2566            }
2567            let loop_args = self.args(bargs.0, "EDGE_LOOP")?;
2568            for oe in loop_args.get(1).and_then(Arg::list).unwrap_or(&[]) {
2569                if let Some(oe_id) = oe.reference() {
2570                    let oargs = self.args(oe_id, "ORIENTED_EDGE")?;
2571                    if let Some(e) = oargs.get(3).and_then(Arg::reference) {
2572                        for key in self.piece_keys(e) {
2573                            *edge_uses.entry(key).or_default() += 1;
2574                        }
2575                    }
2576                }
2577            }
2578        }
2579
2580        let mut wires = Vec::new();
2581        let mut annotated: FastSet<u64> = FastSet::default();
2582        for &bound in &ordered {
2583            let (loop_id, bound_forward) = self.bound_args(bound)?;
2584            if let Some(vertex_loop) = {
2585                let instance = self.instance(loop_id)?;
2586                instance.part("VERTEX_LOOP")
2587            } {
2588                // A loop of one vertex: a pole or an apex. It has no edges,
2589                // but it still bounds the face in parameter space, as a
2590                // degenerate edge running across the chart at the row the
2591                // point collapses to, exactly as native cones and spheres
2592                // are built.
2593                let vertex_id = vertex_loop.get(1).and_then(Arg::reference).unwrap_or(0);
2594                let vargs = self.args(vertex_id, "VERTEX_POINT")?;
2595                let at = self.point(ref_at(vargs, 1))?;
2596                let vertex = self.vertex(vertex_id)?;
2597                let projection = ogeom_algo::project_on_surface(&surface, at, 32, self.tol)?;
2598                let ((ua, ub), _) = surface.domain();
2599                let row = projection.parameters.1;
2600                let mut data = ogeom_topo::EdgeData::new();
2601                data.degenerate = true;
2602                let edge = self
2603                    .model
2604                    .add_edge(data, &[vertex.clone(), vertex.clone()])?;
2605                let pcurve: PlanarCurve = ogeom_geom::Line2d::segment(
2606                    ogeom_math::Point2::new(ua, row),
2607                    ogeom_math::Point2::new(ub, row),
2608                    self.tol,
2609                )?
2610                .into();
2611                ogeom_algo::attach_pcurve(
2612                    &mut self.model,
2613                    &edge,
2614                    pcurve,
2615                    surface_id,
2616                    Location::identity(),
2617                    (0.0, ub - ua),
2618                )?;
2619                wires.push(make_wire(&mut self.model, &[edge], self.tol)?.shape);
2620                continue;
2621            }
2622            let loop_args = self.args(loop_id, "EDGE_LOOP")?;
2623            let mut uses: Vec<BoundUse> = Vec::new();
2624            for oe in loop_args.get(1).and_then(Arg::list).unwrap_or(&[]) {
2625                let Some(oe_id) = oe.reference() else {
2626                    continue;
2627                };
2628                let oargs = self.args(oe_id, "ORIENTED_EDGE")?;
2629                let Some(edge_id) = oargs.get(3).and_then(Arg::reference) else {
2630                    continue;
2631                };
2632                let forward = !oargs.get(4).is_some_and(|a| a.is_enum("F"));
2633                let Some(pieces) = self.edge_pieces(edge_id)? else {
2634                    self.report.warnings.push(format!(
2635                        "#{id}: a bound references unreadable edge #{edge_id}; \
2636                         the face is skipped"
2637                    ));
2638                    return Ok(None);
2639                };
2640                let flipped = pieces.first().is_some_and(|(_, built)| built.3);
2641                // The use's direction composes the loop's, the bound's and
2642                // the edge-against-curve flag. The pieces of a cut edge go
2643                // in the order the loop as written walks them; reversing a
2644                // reversed bound below turns them with the rest.
2645                let mut use_forward = forward == bound_forward;
2646                if flipped {
2647                    use_forward = !use_forward;
2648                }
2649                let mut walked: Vec<BoundUse> = pieces
2650                    .into_iter()
2651                    .map(|(key, (shape, curve, range, _))| {
2652                        let placed = if use_forward {
2653                            shape.clone()
2654                        } else {
2655                            shape.reversed()
2656                        };
2657                        (placed, shape, key, curve, range)
2658                    })
2659                    .collect();
2660                if forward == flipped {
2661                    walked.reverse();
2662                }
2663                uses.extend(walked);
2664            }
2665            if !bound_forward {
2666                uses.reverse();
2667            }
2668            // The window may have grown to hold this bound, and the images
2669            // are derived on the surface as it stands.
2670            self.widen_window(surface_id, &uses)?;
2671            let widened = self
2672                .model
2673                .geometry()
2674                .surface(surface_id)
2675                .cloned()
2676                .unwrap_or_else(|| surface.clone());
2677            self.chart_bound(id, &uses, &widened, surface_id, &edge_uses, &mut annotated)?;
2678            let edges: Vec<Shape> = uses.into_iter().map(|(placed, ..)| placed).collect();
2679            if edges.is_empty() {
2680                continue;
2681            }
2682            wires.push(make_wire(&mut self.model, &edges, self.tol)?.shape);
2683        }
2684        if wires.is_empty() {
2685            self.report
2686                .warnings
2687                .push(format!("#{id}: a face with no readable bounds is skipped"));
2688            return Ok(None);
2689        }
2690        // As the bounds left it: a window stretched to hold them is the
2691        // surface every path below builds on, including the band, which
2692        // registers the value it is handed rather than the one on record.
2693        let surface = self
2694            .model
2695            .geometry()
2696            .surface(surface_id)
2697            .cloned()
2698            .unwrap_or(surface);
2699
2700        // A band whose rim was split on the other rim's column takes its
2701        // seam there. The rings are walked as the file walks them about the
2702        // face's own normal, which already says which side the face faces.
2703        if wires.len() == 2 && self.band_faces.contains(&id) {
2704            match self.band_of_split_rims(&surface, &wires) {
2705                Ok(shape) => {
2706                    self.faces.insert(id, shape.clone());
2707                    return Ok(Some(shape));
2708                }
2709                Err(e) => {
2710                    self.report.warnings.push(format!(
2711                        "#{id}: no seam could be synthesised ({e}); the \
2712                         face may not triangulate"
2713                    ));
2714                }
2715            }
2716        }
2717
2718        // A periodic face bound only by closed rings (a cylinder band
2719        // between two circles) arrives without a seam edge, which is a
2720        // legitimate STEP shape and an open rectangle in this kernel's
2721        // chart. The seam is synthesised the way native cylinders build it:
2722        // one edge at the period join, appearing in the wire twice.
2723        if wires.len() == 2
2724            && surface.is_periodic_u()
2725            && let [(e_lo, _v_lo), (e_hi, _v_hi)] =
2726                closed_ring_edges(&self.model, &wires)?.as_slice()
2727            // Only rings that are parallels of the surface make a band.
2728            // Two closed circles that merely lie on it (a button head's
2729            // rims, square to the screw on a sphere whose chart runs along
2730            // z) bound a legitimate face on their own, nested loops in the
2731            // chart, and take the ordinary path below without a word.
2732            && ogeom_algo::rings_are_parallels(&self.model, &surface, &[e_lo, e_hi], self.tol)?
2733        {
2734            {
2735                // The band construction is ogeom-algo's make_revolution_band:
2736                // one authority shared with the healer. Anything it refuses
2737                // (a spline ring, ring vertices at different angles, a
2738                // surface with no closed-form iso-curve) becomes a warning
2739                // and the raw bounds, not an error.
2740                match ogeom_algo::make_revolution_band(
2741                    &mut self.model,
2742                    &surface,
2743                    e_lo,
2744                    e_hi,
2745                    self.tol,
2746                ) {
2747                    Ok(built) => {
2748                        let shape = if face_forward {
2749                            built
2750                        } else {
2751                            built.reversed()
2752                        };
2753                        self.faces.insert(id, shape.clone());
2754                        return Ok(Some(shape));
2755                    }
2756                    Err(e) => {
2757                        self.report.warnings.push(format!(
2758                            "#{id}: no seam could be synthesised ({e}); the \
2759                             face may not triangulate"
2760                        ));
2761                    }
2762                }
2763            }
2764        }
2765
2766        // A cone face bounded by a *single* closed ring: the apex is the
2767        // other boundary, and some files simply never write it: no vertex
2768        // loop, nothing. The geometry leaves one choice of what the face
2769        // means, so the apex is synthesised and the band built as if the
2770        // file had said so.
2771        if wires.len() == 1
2772            && matches!(surface, SurfaceGeometry::Cone(_))
2773            && let [(ring, _)] = closed_ring_edges(&self.model, &wires)?.as_slice()
2774        {
2775            match ogeom_algo::make_apex_band(&mut self.model, &surface, ring, self.tol) {
2776                Ok(built) => {
2777                    let shape = if face_forward {
2778                        built
2779                    } else {
2780                        built.reversed()
2781                    };
2782                    self.faces.insert(id, shape.clone());
2783                    return Ok(Some(shape));
2784                }
2785                Err(e) => {
2786                    self.report.warnings.push(format!(
2787                        "#{id}: no apex could be synthesised ({e}); the face \
2788                         may not triangulate"
2789                    ));
2790                }
2791            }
2792        }
2793
2794        // A sphere face bounded by a single parallel: the pole on the side
2795        // the face's material lies is the other boundary, which files leave
2796        // unwritten as they leave a cone's apex. The ring is walked with the
2797        // material on its left about the face's own normal, so the pole
2798        // that left points toward is the one the face reaches.
2799        if wires.len() == 1
2800            && let SurfaceGeometry::Sphere(ball) = &surface
2801            && let [(ring, at)] = closed_ring_edges(&self.model, &wires)?.as_slice()
2802            && ogeom_algo::rings_are_parallels(&self.model, &surface, &[ring], self.tol)?
2803            && let Some(pole) = pole_on_the_left(
2804                &self.model,
2805                &surface,
2806                ball.sphere(),
2807                ring,
2808                at,
2809                face_forward,
2810                self.tol,
2811            )?
2812        {
2813            let vertex = self.model.add_vertex(ogeom_topo::VertexData::new(pole));
2814            let mut data = ogeom_topo::EdgeData::new();
2815            data.degenerate = true;
2816            let collapsed = self.model.add_edge(data, &[vertex.clone(), vertex])?;
2817            match ogeom_algo::make_revolution_band(
2818                &mut self.model,
2819                &surface,
2820                ring,
2821                &collapsed,
2822                self.tol,
2823            ) {
2824                Ok(built) => {
2825                    let shape = if face_forward {
2826                        built
2827                    } else {
2828                        built.reversed()
2829                    };
2830                    self.faces.insert(id, shape.clone());
2831                    return Ok(Some(shape));
2832                }
2833                Err(e) => {
2834                    self.report.warnings.push(format!(
2835                        "#{id}: no pole could be synthesised ({e}); the face \
2836                         may not triangulate"
2837                    ));
2838                }
2839            }
2840        }
2841
2842        // A spline closed round with no seam written: the rings close into
2843        // one loop up and down the period's join.
2844        if let Some(belt) = ogeom_algo::belted_wire(&mut self.model, surface_id, &wires, self.tol)?
2845        {
2846            wires = vec![belt];
2847        }
2848        // Every wire's images on one branch of a periodic chart: a file's
2849        // pcurves, or fits of them, answer in whatever phase they like, and
2850        // a hole loop straddling a drum's seam arrives half on each branch.
2851        ogeom_algo::chain_wire_branches(&mut self.model, surface_id, &wires, self.tol)?;
2852        // With every image on one branch, a boundary meeting a pole from two
2853        // sides of the chart is closed across the pole row.
2854        for wire in &mut wires {
2855            let edges = self.model.ordered_children_of(wire)?;
2856            let closed =
2857                ogeom_algo::closed_at_poles(&mut self.model, surface_id, &edges, self.tol)?;
2858            if closed.len() != edges.len() {
2859                *wire = make_wire(&mut self.model, &closed, self.tol)?.shape;
2860            }
2861        }
2862        // A file walks each loop with the face's material on its left about
2863        // the face's own normal; a face turned against its surface is stored
2864        // with its loops walked back, so that turning it walks them as
2865        // written.
2866        if !face_forward {
2867            wires = walked_back(&mut self.model, &wires, self.tol)?;
2868        }
2869        let mut built = make_face_on(&mut self.model, surface_id, &wires, self.tol)?.shape;
2870        // A pole crossed a whole turn round, beside a joint a whole period
2871        // apart where `closed_at_poles` lays no run: the crossing solved
2872        // for the face as built, and its wires rebuilt with the runs in.
2873        let crossed = ogeom_algo::pole_crossings(&mut self.model, &built, self.tol)?;
2874        if !crossed.is_empty() {
2875            for wire in &mut wires {
2876                if let Some((_, new)) = crossed.iter().find(|(old, _)| old.node() == wire.node()) {
2877                    *wire = new.clone();
2878                }
2879            }
2880            built = make_face_on(&mut self.model, surface_id, &wires, self.tol)?.shape;
2881            self.report.warnings.push(format!(
2882                "#{id}: the boundary crosses a pole of its surface a whole turn \
2883                 round; a degenerate edge was laid across it"
2884            ));
2885            self.tally("pole-crossed", 0.0, id);
2886        }
2887        let shape = if face_forward {
2888            built
2889        } else {
2890            built.reversed()
2891        };
2892        self.faces.insert(id, shape.clone());
2893        Ok(Some(shape))
2894    }
2895
2896    /// A bound's loop and orientation, whichever of the two bound kinds it is.
2897    fn bound_args(&mut self, id: u64) -> OgeomResult<(u64, bool)> {
2898        let instance = self.instance(id)?;
2899        let args = instance
2900            .part("FACE_OUTER_BOUND")
2901            .or_else(|| instance.part("FACE_BOUND"))
2902            .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "#{id} is not a face bound"))?
2903            .to_vec();
2904        let loop_id = args.get(1).and_then(Arg::reference).unwrap_or(0);
2905        let forward = !args.get(2).is_some_and(|a| a.is_enum("F"));
2906        Ok((loop_id, forward))
2907    }
2908
2909    /// Stretch a surface's parameter window to hold the edges that bound it.
2910    ///
2911    /// The window a reader gives a plane, a cylinder or a cone is a
2912    /// convention: those surfaces are unbounded, and [`SURFACE_EXTENT`] is
2913    /// a guess at how far past its own geometry a file will reach. A file
2914    /// can falsify the guess: a surface whose own origin sits far from the
2915    /// face it carries has trim parameters past any fixed window, the
2916    /// surface refuses to be evaluated where its own face lies, and the
2917    /// face draws as a hole.
2918    ///
2919    /// So the window is measured rather than guessed: the edges' own
2920    /// points, projected onto the surface's chart by the geometry that
2921    /// defines it, with the guess kept as a floor. Widening is safe for a
2922    /// surface other faces share, since a window only ever grows.
2923    fn widen_window(
2924        &mut self,
2925        surface_id: ogeom_topo::SurfaceId,
2926        uses: &[BoundUse],
2927    ) -> OgeomResult<()> {
2928        use ogeom_geom::Curve3d as _;
2929        let Some(surface) = self.model.geometry().surface(surface_id).cloned() else {
2930            return Ok(());
2931        };
2932        // Where each surface keeps its unbounded directions, and how a point
2933        // of space reads on them.
2934        let along: Box<ChartReading> = match &surface {
2935            SurfaceGeometry::Plane(p) => {
2936                let frame = p.plane().frame();
2937                let (origin, x, y) = (frame.origin(), frame.x().vector(), frame.y().vector());
2938                Box::new(move |at: Point| (Some((at - origin).dot(x)), Some((at - origin).dot(y))))
2939            }
2940            SurfaceGeometry::Cylinder(c) => {
2941                let axis = c.cylinder().axis();
2942                let (origin, direction) = (axis.location, axis.direction.vector());
2943                Box::new(move |at: Point| (None, Some((at - origin).dot(direction))))
2944            }
2945            SurfaceGeometry::Cone(c) => {
2946                let axis = c.cone().axis();
2947                let (origin, direction) = (axis.location, axis.direction.vector());
2948                Box::new(move |at: Point| (None, Some((at - origin).dot(direction))))
2949            }
2950            // A swept curve: a point's sweep parameter is its reach along
2951            // the direction less the swept curve's own, read against the
2952            // middle of the curve's reach; the curve's half-reach either
2953            // way is inside the margin the window grows by.
2954            SurfaceGeometry::Extrusion(e) => {
2955                let direction = e.direction().vector();
2956                let (lo, hi) = e.curve().domain();
2957                let (mut least, mut most) = (f64::INFINITY, f64::NEG_INFINITY);
2958                for k in 0..=32 {
2959                    let t = lo + (hi - lo) * f64::from(k) / 32.0;
2960                    if let Ok(p) = e.curve().point_at(t, self.tol) {
2961                        let reach = p.to_vector().dot(direction);
2962                        least = least.min(reach);
2963                        most = most.max(reach);
2964                    }
2965                }
2966                let middle = f64::midpoint(least, most);
2967                Box::new(move |at: Point| (None, Some(at.to_vector().dot(direction) - middle)))
2968            }
2969            _ => return Ok(()),
2970        };
2971        const STATIONS: usize = 4;
2972        let (mut ua, mut ub) = (-SURFACE_EXTENT, SURFACE_EXTENT);
2973        let (mut va, mut vb) = (-SURFACE_EXTENT, SURFACE_EXTENT);
2974        for (_, _, _, curve, range) in uses {
2975            for step in 0..=STATIONS {
2976                #[allow(clippy::cast_precision_loss)]
2977                let t = range.0 + (range.1 - range.0) * (step as f64 / STATIONS as f64);
2978                let Ok(at) = curve.point_at(t, self.tol) else {
2979                    continue;
2980                };
2981                let (u, v) = along(at);
2982                if let Some(u) = u {
2983                    ua = ua.min(u);
2984                    ub = ub.max(u);
2985                }
2986                if let Some(v) = v {
2987                    va = va.min(v);
2988                    vb = vb.max(v);
2989                }
2990            }
2991        }
2992        let ((was_u, was_v), grown) = (surface.domain(), (ub - ua) * 0.05);
2993        if ua >= was_u.0 && ub <= was_u.1 && va >= was_v.0 && vb <= was_v.1 {
2994            return Ok(());
2995        }
2996        let (u, v) = (
2997            (ua.min(was_u.0) - grown, ub.max(was_u.1) + grown),
2998            (va.min(was_v.0) - grown, vb.max(was_v.1) + grown),
2999        );
3000        let wider = match surface {
3001            SurfaceGeometry::Plane(p) => PlaneSurface::over(p.plane(), u, v)?.into(),
3002            SurfaceGeometry::Cylinder(c) => CylinderSurface::new(c.cylinder(), v)?.into(),
3003            SurfaceGeometry::Cone(c) => ConeSurface::new(c.cone(), v)?.into(),
3004            SurfaceGeometry::Extrusion(e) => {
3005                ExtrusionSurface::over(e.curve().clone(), e.direction(), v)?.into()
3006            }
3007            other => other,
3008        };
3009        if let Some(held) = self.model.geometry_mut().surface_mut(surface_id) {
3010            *held = wider;
3011        }
3012        Ok(())
3013    }
3014
3015    /// One bound's images, chained around the face's chart.
3016    ///
3017    /// Each is derived on its own (the exact projection where the pair has
3018    /// a closed form, the fitted one where it does not), and a periodic
3019    /// chart then leaves a branch to choose. Chosen edge by edge the choice
3020    /// is arbitrary and the wire comes apart: a drilled block's bore wall
3021    /// reads back with one rim two whole turns from the other, both the
3022    /// right circle and neither meeting the seam the wire closes on. So
3023    /// each image after the first is slid by whole periods until its start
3024    /// meets where the last one ended.
3025    ///
3026    /// A seam falls out of the same walk. The wire uses it twice, up one
3027    /// column of the chart and down the other, and those columns are one
3028    /// image a period apart, so the walk finds both, and the use that runs
3029    /// forward is the forward side. Where it cannot, because the chart
3030    /// closes without being periodic, the other column goes a chart's width
3031    /// over, toward the middle.
3032    fn chart_bound(
3033        &mut self,
3034        face_id: u64,
3035        uses: &[BoundUse],
3036        surface: &SurfaceGeometry,
3037        surface_id: ogeom_topo::SurfaceId,
3038        edge_uses: &FastMap<u64, usize>,
3039        annotated: &mut FastSet<u64>,
3040    ) -> OgeomResult<()> {
3041        use ogeom_geom::Curve2d as _;
3042        let mut images: FastMap<u64, PlanarCurve> = FastMap::default();
3043        let mut sides: FastMap<u64, (Option<PlanarCurve>, Option<PlanarCurve>)> =
3044            FastMap::default();
3045        let mut order: Vec<u64> = Vec::new();
3046        let mut previous: Option<ogeom_math::Point2> = None;
3047        let mut opening: Option<(u64, bool, f64)> = None;
3048        for (placed, shape, edge_id, curve, range) in uses {
3049            if !images.contains_key(edge_id) {
3050                let Some(image) =
3051                    self.image_for(face_id, *edge_id, shape, curve, *range, surface)?
3052                else {
3053                    continue;
3054                };
3055                images.insert(*edge_id, image);
3056                order.push(*edge_id);
3057            }
3058            let Some(image) = images.get(edge_id).cloned() else {
3059                continue;
3060            };
3061            let backwards = placed.orientation() == ogeom_topo::Orientation::Reversed;
3062            let (start, end) = if backwards {
3063                (range.1, range.0)
3064            } else {
3065                (range.0, range.1)
3066            };
3067            if previous.is_none() && edge_uses.get(edge_id).copied().unwrap_or(1) == 1 {
3068                opening = Some((*edge_id, backwards, start));
3069            }
3070            let mut image =
3071                crate::pcurves::shifted_to_meet(&image, start, previous, surface, self.tol)?;
3072            let walked = sides.entry(*edge_id).or_default();
3073            let first = if backwards { &walked.0 } else { &walked.1 };
3074            if edge_uses.get(edge_id).copied().unwrap_or(1) > 1
3075                && let Some(first) = first
3076            {
3077                image = crate::pcurves::across_from(image, first, *range, surface, self.tol)?;
3078            }
3079            previous = Some(image.point_at(end, self.tol)?);
3080            if backwards {
3081                walked.1 = Some(image);
3082            } else {
3083                walked.0 = Some(image);
3084            }
3085        }
3086        // The first image, which no edge before it placed, meets the last
3087        // across a closing line it lies on.
3088        if let (Some((edge_id, backwards, start)), Some(end)) = (opening, previous)
3089            && let Some(walked) = sides.get_mut(&edge_id)
3090        {
3091            let side = if backwards {
3092                &mut walked.1
3093            } else {
3094                &mut walked.0
3095            };
3096            if let Some(image) = side.as_ref() {
3097                *side = Some(crate::pcurves::onto_closing_side(
3098                    image, start, end, surface, self.tol,
3099                )?);
3100            }
3101        }
3102        for edge_id in order {
3103            if !annotated.insert(edge_id) {
3104                continue;
3105            }
3106            let Some((shape, range)) = uses
3107                .iter()
3108                .find(|(_, _, id, _, _)| *id == edge_id)
3109                .map(|(_, shape, _, _, range)| (shape.clone(), *range))
3110            else {
3111                continue;
3112            };
3113            let Some(image) = images.get(&edge_id).cloned() else {
3114                continue;
3115            };
3116            let walked = sides.remove(&edge_id).unwrap_or((None, None));
3117            let seam = edge_uses.get(&edge_id).copied().unwrap_or(1) > 1;
3118            let columns = match &walked {
3119                (Some(forward), Some(reversed)) => {
3120                    let at = forward.point_at(range.0, self.tol)?;
3121                    let other = reversed.point_at(range.0, self.tol)?;
3122                    (at.distance(other) > self.tol.confusion())
3123                        .then(|| (forward.clone(), reversed.clone()))
3124                }
3125                _ => None,
3126            };
3127            match (seam, columns) {
3128                (true, Some((forward, reversed))) => {
3129                    self.record_columns(&shape, forward, reversed, surface_id, range)?;
3130                }
3131                (true, None) => {
3132                    self.record_pcurve(&shape, image, surface_id, true, range)?;
3133                }
3134                (false, _) => {
3135                    let (forward, reversed) = walked;
3136                    let placed = forward.or(reversed).unwrap_or(image);
3137                    self.record_pcurve(&shape, placed, surface_id, false, range)?;
3138                }
3139            }
3140        }
3141        Ok(())
3142    }
3143
3144    fn image_for(
3145        &mut self,
3146        face_id: u64,
3147        edge_id: u64,
3148        edge: &Shape,
3149        curve: &Curve,
3150        range: (f64, f64),
3151        surface: &SurfaceGeometry,
3152    ) -> OgeomResult<Option<PlanarCurve>> {
3153        // Taken from the table where the pass at the head of the solid
3154        // already derived it. The fallbacks below serve the faces no pass
3155        // covered: a face reached outside a solid walk, or one whose
3156        // preparation refused.
3157        let (prepared, offered) = match self.pcurves.remove(&(face_id, edge_id)) {
3158            Some(prepared) => prepared,
3159            None => {
3160                let stated = self.stated_pcurves(face_id, edge_id);
3161                let offered = !stated.is_empty();
3162                (
3163                    derive_pcurve(curve, range, surface, &stated, self.tol),
3164                    offered,
3165                )
3166            }
3167        };
3168        if matches!(prepared, PreparedPcurve::Stated { .. }) {
3169            self.report.pcurves_kept += 1;
3170        } else if offered {
3171            self.report.pcurves_derived += 1;
3172        }
3173        let (pcurve, off) = match prepared {
3174            PreparedPcurve::Exact { curve, off } | PreparedPcurve::Stated { curve, off } => {
3175                (crate::pcurves::over_range(&curve, range), off)
3176            }
3177            PreparedPcurve::Fitted {
3178                curve: fitted,
3179                error,
3180                met,
3181                worst_off,
3182                off,
3183                warning,
3184            } => {
3185                if let Some(w) = warning {
3186                    self.warn_slop(w, worst_off, face_id);
3187                }
3188                if !met {
3189                    self.warn_fit_short(face_id, error);
3190                }
3191                (fitted, off.max(worst_off))
3192            }
3193            PreparedPcurve::Refused(why) => {
3194                self.report.warnings.push(format!(
3195                    "face #{face_id}: no pcurve for an edge on this surface ({why}); \
3196                     the face may not triangulate"
3197                ));
3198                self.note_untrimmed(face_id);
3199                return Ok(None);
3200            }
3201        };
3202        // The edge provably stands `off` from the face it bounds, whether
3203        // the curve lies off the surface or a fit strays between its
3204        // samples; its tolerance grows to cover that, and its vertices
3205        // follow once the solid is whole. Past the millimetre a file's
3206        // slop stays under, the pcurve is a fit gone astray (a curve
3207        // through a pole left whole, whose image leaps across the chart
3208        // there), and
3209        // an edge that wide would swallow its neighbours; the face is
3210        // reported instead.
3211        if off > self.tol.confusion() * 1e7 {
3212            self.warn_fit_short(face_id, off);
3213        } else if off > self.tol.confusion()
3214            && let Some(node) = self.model.node_mut(edge)
3215            && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
3216        {
3217            data.tolerance = data.tolerance.widen_to(off + self.tol.confusion());
3218        }
3219        Ok(Some(pcurve))
3220    }
3221
3222    /// The pcurves the file states for an edge on a face's surface. A key
3223    /// standing for a piece of an edge cut at a pole names no edge in the
3224    /// file, and the file's pcurves run over the whole edge in any case.
3225    fn stated_pcurves(&mut self, face_id: u64, edge_id: u64) -> Vec<PlanarCurve> {
3226        if self.pieces.contains_key(&edge_id) || !self.instances.contains(edge_id) {
3227            return Vec::new();
3228        }
3229        let Some(surface_id) = self
3230            .face_args(face_id)
3231            .ok()
3232            .and_then(|args| args.get(2).and_then(Arg::reference))
3233        else {
3234            return Vec::new();
3235        };
3236        self.file_pcurves(edge_id, surface_id)
3237    }
3238
3239    /// Count one occurrence of a warning kind toward the summary.
3240    fn tally(&mut self, kind: &'static str, measured: f64, exemplar: u64) {
3241        let entry = self.tallies.entry(kind).or_insert((0, 0.0, exemplar));
3242        entry.0 += 1;
3243        if measured > entry.1 {
3244            entry.1 = measured;
3245            entry.2 = exemplar;
3246        }
3247    }
3248
3249    /// A curve end missing its vertex: the prose, and the count.
3250    fn warn_vertex_miss(&mut self, id: u64, gap: f64) {
3251        self.report.warnings.push(format!(
3252            "#{id}: a curve end misses its vertex by {gap:.2e}; the vertex \
3253             tolerance grew to say so"
3254        ));
3255        self.tally("vertex-miss", gap, id);
3256    }
3257
3258    /// A fitted trim that stopped short of its target: prose and count.
3259    fn warn_fit_short(&mut self, face_id: u64, error: f64) {
3260        self.report.warnings.push(format!(
3261            "face #{face_id}: a projected pcurve fit stopped at {error:.2e}; \
3262             the face's mesh may sit that far off along this edge"
3263        ));
3264        self.tally("fit-short", error, face_id);
3265    }
3266
3267    /// The file's own boundary slop, carried and counted.
3268    fn warn_slop(&mut self, prose: String, worst: f64, exemplar: u64) {
3269        self.report.warnings.push(prose);
3270        self.tally("boundary-slop", worst, exemplar);
3271    }
3272
3273    /// Note a face left without a complete trim, once.
3274    fn note_untrimmed(&mut self, face_id: u64) {
3275        if self.untrimmed_ids.last() != Some(&face_id) {
3276            self.untrimmed_ids.push(face_id);
3277            self.tally("untrimmed", 0.0, face_id);
3278        }
3279    }
3280
3281    /// Attach a seam whose two columns the wire's own walk already found.
3282    fn record_columns(
3283        &mut self,
3284        edge: &Shape,
3285        forward: PlanarCurve,
3286        reversed: PlanarCurve,
3287        surface_id: ogeom_topo::SurfaceId,
3288        range: (f64, f64),
3289    ) -> OgeomResult<()> {
3290        ogeom_algo::attach_seam(
3291            &mut self.model,
3292            edge,
3293            forward,
3294            reversed,
3295            surface_id,
3296            Location::identity(),
3297            range,
3298        )
3299    }
3300
3301    /// Attach a derived pcurve, seaming it where the edge bounds the chart
3302    /// twice and the walk could not say where its other column stands.
3303    fn record_pcurve(
3304        &mut self,
3305        edge: &Shape,
3306        pcurve: PlanarCurve,
3307        surface_id: ogeom_topo::SurfaceId,
3308        seam: bool,
3309        range: (f64, f64),
3310    ) -> OgeomResult<()> {
3311        if seam {
3312            let Some(surface) = self.model.geometry().surface(surface_id).cloned() else {
3313                ogeom_bail!(Dangling, "the surface is not in this model");
3314            };
3315            // One side is where the projection landed; the other is one
3316            // period over, along the axis the surface actually closes on.
3317            let other = crate::pcurves::seam_other_side(&pcurve, range, &surface, self.tol)?;
3318            ogeom_algo::attach_seam(
3319                &mut self.model,
3320                edge,
3321                pcurve,
3322                other,
3323                surface_id,
3324                Location::identity(),
3325                range,
3326            )?;
3327        } else {
3328            ogeom_algo::attach_pcurve(
3329                &mut self.model,
3330                edge,
3331                pcurve,
3332                surface_id,
3333                Location::identity(),
3334                range,
3335            )?;
3336        }
3337        Ok(())
3338    }
3339
3340    /// Derive every pcurve this solid's faces will want, in parallel.
3341    ///
3342    /// Deriving one is a pure function of a curve, its range and a surface:
3343    /// it reads no model and depends on no order, and it is most of the time
3344    /// spent building solids. The walk that follows attaches them, in file
3345    /// order.
3346    ///
3347    /// Resolving *what* to derive still runs on the walk's own thread, since
3348    /// it builds edges and vertices into the model. That part is cheap; it is
3349    /// the projection and the fitting that are not.
3350    ///
3351    /// Best-effort by design: anything this cannot resolve is simply left out
3352    /// of the table, and `chart_bound` derives it on the walk. So a face
3353    /// shape this does not anticipate costs time, never correctness.
3354    fn prepare_pcurves(&mut self, face_ids: &[u64]) {
3355        struct Job {
3356            face: u64,
3357            edge: u64,
3358            curve: Curve,
3359            range: (f64, f64),
3360            /// The pcurves the file states for it, tried first.
3361            stated: Vec<PlanarCurve>,
3362            /// Index into `surfaces`. The surface is held once per face, not
3363            /// once per edge: a B-spline patch owns its whole control grid,
3364            /// and cloning that per edge costs more than the projection it
3365            /// was cloned for.
3366            surface: usize,
3367        }
3368        let mut surfaces: Vec<SurfaceGeometry> = Vec::new();
3369        let mut jobs: Vec<Job> = Vec::new();
3370        let mut seen: FastSet<(u64, u64)> = FastSet::default();
3371        for &fid in face_ids {
3372            let Ok(args) = self.face_args(fid) else {
3373                continue;
3374            };
3375            let Some(surface) = args
3376                .get(2)
3377                .and_then(Arg::reference)
3378                .and_then(|sid| self.surface(sid).ok().flatten())
3379            else {
3380                continue;
3381            };
3382            surfaces.push(surface);
3383            let at = surfaces.len() - 1;
3384            let bounds: Vec<u64> = args
3385                .get(1)
3386                .and_then(Arg::list)
3387                .unwrap_or(&[])
3388                .iter()
3389                .filter_map(Arg::reference)
3390                .collect();
3391            for bound in bounds {
3392                let Ok((loop_id, _)) = self.bound_args(bound) else {
3393                    continue;
3394                };
3395                let Ok(loop_args) = self.args(loop_id, "EDGE_LOOP") else {
3396                    continue;
3397                };
3398                let uses: Vec<u64> = loop_args
3399                    .get(1)
3400                    .and_then(Arg::list)
3401                    .unwrap_or(&[])
3402                    .iter()
3403                    .filter_map(Arg::reference)
3404                    .collect();
3405                for oe_id in uses {
3406                    let Ok(oargs) = self.args(oe_id, "ORIENTED_EDGE") else {
3407                        continue;
3408                    };
3409                    let Some(edge_id) = oargs.get(3).and_then(Arg::reference) else {
3410                        continue;
3411                    };
3412                    if !seen.insert((fid, edge_id)) {
3413                        continue;
3414                    }
3415                    if let Ok(Some(pieces)) = self.edge_pieces(edge_id) {
3416                        for (key, (_, curve, range, _)) in pieces {
3417                            let stated = self.stated_pcurves(fid, key);
3418                            jobs.push(Job {
3419                                face: fid,
3420                                edge: key,
3421                                curve,
3422                                range,
3423                                stated,
3424                                surface: at,
3425                            });
3426                        }
3427                    }
3428                }
3429            }
3430        }
3431        // Below a handful of edges the threads cost more than the work; the
3432        // sequential path through `chart_bound` is already correct, so the
3433        // table is simply left empty and the walk derives them itself.
3434        if jobs.len() < 16 {
3435            return;
3436        }
3437        let tol = self.tol;
3438        let derived = ogeom_core::parallel::map_ordered(&jobs, |_, job| {
3439            derive_pcurve(
3440                &job.curve,
3441                job.range,
3442                &surfaces[job.surface],
3443                &job.stated,
3444                tol,
3445            )
3446        });
3447        for (job, pcurve) in jobs.iter().zip(derived) {
3448            self.pcurves
3449                .insert((job.face, job.edge), (pcurve, !job.stated.is_empty()));
3450        }
3451    }
3452
3453    /// The solid a `MANIFOLD_SOLID_BREP` names: its shell's faces, sewn.
3454    ///
3455    /// `BREP_WITH_VOIDS` is the same entity with cavities: a subtype of
3456    /// `MANIFOLD_SOLID_BREP`, so its first two attributes are the name and
3457    /// the outer shell, and a third names the shells that bound the voids.
3458    /// A reader matching on the leading keyword alone does not see it, and
3459    /// the part vanishes from the import. The voids join the solid as
3460    /// shells of their own, every normal pointing away from the material:
3461    /// out of the body on the outside, into the cavity within. A void is
3462    /// oriented by its geometry where its flags
3463    /// would have it add material.
3464    fn solid(&mut self, id: u64) -> OgeomResult<Shape> {
3465        let instance = self.instance(id)?;
3466        let args = instance
3467            .part("MANIFOLD_SOLID_BREP")
3468            .or_else(|| instance.part("BREP_WITH_VOIDS"))
3469            .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "#{id} is not a solid"))?
3470            .to_vec();
3471        let shell_id = args.get(1).and_then(Arg::reference).unwrap_or(0);
3472        let Some(shell) = self.shell(shell_id)? else {
3473            ogeom_bail!(Construction, "#{id}: a solid with no readable faces");
3474        };
3475        if !ogeom_algo::is_shell_closed(&self.model, &shell)? {
3476            self.report.warnings.push(format!(
3477                "#{id}: the shell does not close as read; measures needing an \
3478                 inside will refuse it"
3479            ));
3480        }
3481        let mut shells = vec![shell];
3482        for void_id in args
3483            .get(2)
3484            .and_then(Arg::list)
3485            .unwrap_or(&[])
3486            .iter()
3487            .filter_map(Arg::reference)
3488            .collect::<Vec<u64>>()
3489        {
3490            match self.shell(void_id) {
3491                Ok(Some(void)) => {
3492                    // A void is a cavity whatever its flags say: its faces
3493                    // point into it, so it encloses negative volume. One a
3494                    // writer turned twice reads as added material, and is
3495                    // turned back as it is built.
3496                    let encloses = ogeom_mesh::triangulate(
3497                        &self.model,
3498                        &void,
3499                        ogeom_mesh::Deflection::default(),
3500                        self.tol,
3501                    )
3502                    .ok()
3503                    .filter(ogeom_topo::Triangulation::is_closed)
3504                    .map(|mesh| mesh.volume());
3505                    if encloses.is_some_and(|v| v > 0.0) {
3506                        self.report.warnings.push(format!(
3507                            "#{id}: void shell #{void_id} was written the wrong way \
3508                             out; it is read as the cavity it bounds"
3509                        ));
3510                        shells.push(void.reversed());
3511                    } else {
3512                        shells.push(void);
3513                    }
3514                }
3515                Ok(None) => self.report.warnings.push(format!(
3516                    "#{id}: void shell #{void_id} has no readable faces; the \
3517                     cavity is missing from the solid"
3518                )),
3519                Err(refusal) => self.report.warnings.push(format!(
3520                    "#{id}: void shell #{void_id} could not be read ({refusal}); \
3521                     the cavity is missing from the solid"
3522                )),
3523            }
3524        }
3525        Ok(make_solid(&mut self.model, &shells)?.shape)
3526    }
3527
3528    /// The shape a `SHELL_BASED_SURFACE_MODEL` names: each of its shells
3529    /// sewn from its faces, a compound of them when there are several.
3530    ///
3531    /// A surface body is what a modeller exports for a part built from
3532    /// faces rather than from a solid, often one single-face body per face,
3533    /// and a reader that walks only `MANIFOLD_SOLID_BREP` leaves such a
3534    /// part invisible. The
3535    /// shells stay shells: the file did not call them solids, and a closed
3536    /// one is still the file's surface model, not this reader's promotion.
3537    fn surface_model(&mut self, id: u64) -> OgeomResult<Shape> {
3538        let args = self.args(id, "SHELL_BASED_SURFACE_MODEL")?;
3539        let shell_ids: Vec<u64> = args
3540            .get(1)
3541            .and_then(Arg::list)
3542            .unwrap_or(&[])
3543            .iter()
3544            .filter_map(Arg::reference)
3545            .collect();
3546        let mut shells = Vec::new();
3547        for shell_id in shell_ids {
3548            if let Some(shell) = self.shell(shell_id)? {
3549                shells.push(shell);
3550            }
3551        }
3552        match shells.len() {
3553            0 => ogeom_bail!(
3554                Construction,
3555                "#{id}: a surface model with no readable faces"
3556            ),
3557            1 => Ok(shells.remove(0)),
3558            _ => Ok(ogeom_algo::build::make_compound(&mut self.model, &shells)?.shape),
3559        }
3560    }
3561
3562    /// The faces of a `CLOSED_SHELL` or `OPEN_SHELL`, sewn; `None` when not
3563    /// one of them could be read.
3564    ///
3565    /// An `ORIENTED_CLOSED_SHELL` is a use of another shell the other way
3566    /// round (how a solid's voids are named) and resolves to that shell,
3567    /// reversed when the use says so.
3568    fn shell(&mut self, shell_id: u64) -> OgeomResult<Option<Shape>> {
3569        self.nested(|reader| reader.shell_at(shell_id))
3570    }
3571
3572    fn shell_at(&mut self, shell_id: u64) -> OgeomResult<Option<Shape>> {
3573        let shell_instance = self.instance(shell_id)?;
3574        if let Some(oriented) = shell_instance.part("ORIENTED_CLOSED_SHELL") {
3575            let Some(base) = oriented.get(2).and_then(Arg::reference) else {
3576                ogeom_bail!(Construction, "#{shell_id}: an oriented shell names none");
3577            };
3578            let forward = !oriented.get(3).is_some_and(|a| a.is_enum("F"));
3579            return Ok(self
3580                .shell(base)?
3581                .map(|shell| if forward { shell } else { shell.reversed() }));
3582        }
3583        let shell_args = shell_instance
3584            .part("CLOSED_SHELL")
3585            .or_else(|| shell_instance.part("OPEN_SHELL"))
3586            .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "#{shell_id} is not a shell"))?
3587            .to_vec();
3588        let face_ids: Vec<u64> = shell_args
3589            .get(1)
3590            .and_then(Arg::list)
3591            .unwrap_or(&[])
3592            .iter()
3593            .filter_map(Arg::reference)
3594            .collect();
3595        self.cut_at_poles(&face_ids);
3596        self.split_band_rims(&face_ids);
3597        self.prepare_pcurves(&face_ids);
3598        let mut read = Vec::new();
3599        for fid in face_ids {
3600            if let Some(face) = self.face(fid)? {
3601                read.push((fid, face));
3602            }
3603        }
3604        if read.is_empty() {
3605            return Ok(None);
3606        }
3607        self.turn_backward_loops(shell_id, &mut read)?;
3608        let faces: Vec<Shape> = read.into_iter().map(|(_, face)| face).collect();
3609        Ok(Some(make_shell(&mut self.model, &faces)?.shape))
3610    }
3611
3612    /// Walk back the loops of the faces a shell's own edges say run
3613    /// against their sense.
3614    ///
3615    /// Each edge between two faces is walked once each way, every face
3616    /// keeping its material on the left of its loops. A file whose faces
3617    /// turned against their surfaces were written with their loops walked
3618    /// backward has such faces walking every edge the same way as the face
3619    /// beside them. Where the shell's walks pick out a set of faces whose
3620    /// loops, walked back, leave every edge walked once each way, and every
3621    /// face in the set is turned against its surface, those faces are
3622    /// rebuilt with their loops walked back, and the report says how many.
3623    /// A shell whose walks pick out no such set is left as read.
3624    fn turn_backward_loops(&mut self, shell_id: u64, read: &mut [(u64, Shape)]) -> OgeomResult<()> {
3625        // Each edge's uses: the face, and whether it walks the edge forward.
3626        let mut uses: FastMap<(ogeom_topo::TShapeId, Location), Vec<(usize, bool)>> =
3627            FastMap::default();
3628        for (i, (_, face)) in read.iter().enumerate() {
3629            for wire in self.model.children_of(face)? {
3630                for edge in self.model.children_of(&wire)? {
3631                    if self
3632                        .model
3633                        .node(&edge)
3634                        .and_then(|n| n.data().as_edge())
3635                        .is_some_and(|d| d.degenerate)
3636                    {
3637                        continue;
3638                    }
3639                    uses.entry((edge.node(), edge.location().clone()))
3640                        .or_default()
3641                        .push((i, edge.orientation() == ogeom_topo::Orientation::Forward));
3642                }
3643            }
3644        }
3645        // Whether each face's loops must be walked back relative to a
3646        // reference face of its group: walked the same way by two faces,
3647        // an edge asks one of the two to turn; once each way, both or
3648        // neither.
3649        let pairs: Vec<(usize, usize, bool)> = uses
3650            .values()
3651            .filter_map(|u| match u.as_slice() {
3652                [(a, x), (b, y)] if a != b => Some((*a, *b, x == y)),
3653                _ => None,
3654            })
3655            .collect();
3656        if !pairs.iter().any(|&(_, _, same)| same) {
3657            return Ok(());
3658        }
3659        let mut beside: Vec<Vec<(usize, bool)>> = vec![Vec::new(); read.len()];
3660        for &(a, b, same) in &pairs {
3661            beside[a].push((b, same));
3662            beside[b].push((a, same));
3663        }
3664        let mut turn: Vec<Option<bool>> = vec![None; read.len()];
3665        let mut groups: Vec<Vec<usize>> = Vec::new();
3666        for start in 0..read.len() {
3667            if turn[start].is_some() {
3668                continue;
3669            }
3670            turn[start] = Some(false);
3671            let mut group = vec![start];
3672            let mut k = 0;
3673            while k < group.len() {
3674                let f = group[k];
3675                k += 1;
3676                let mine = turn[f] == Some(true);
3677                for &(g, same) in &beside[f] {
3678                    let want = mine ^ same;
3679                    match turn[g] {
3680                        None => {
3681                            turn[g] = Some(want);
3682                            group.push(g);
3683                        }
3684                        Some(had) if had != want => return Ok(()),
3685                        Some(_) => {}
3686                    }
3687                }
3688            }
3689            groups.push(group);
3690        }
3691        let backward = |i: usize| read[i].1.orientation() == ogeom_topo::Orientation::Reversed;
3692        let mut walk_back = Vec::new();
3693        for group in groups {
3694            let (marked, unmarked): (Vec<usize>, Vec<usize>) =
3695                group.iter().partition(|&&f| turn[f] == Some(true));
3696            let candidates = [marked, unmarked];
3697            let Some(chosen) = candidates
3698                .iter()
3699                .filter(|set| set.iter().all(|&f| backward(f)))
3700                .min_by_key(|set| set.len())
3701            else {
3702                return Ok(());
3703            };
3704            walk_back.extend(chosen.iter().copied());
3705        }
3706        if walk_back.is_empty() {
3707            return Ok(());
3708        }
3709        for &i in &walk_back {
3710            let (fid, face) = read[i].clone();
3711            let built = face.oriented(ogeom_topo::Orientation::Forward);
3712            let Some(surface_id) = self
3713                .model
3714                .node(&built)
3715                .and_then(|n| n.data().as_face())
3716                .map(|d| d.surface)
3717            else {
3718                ogeom_bail!(Construction, "#{fid}: a read face holds no face data");
3719            };
3720            let wires = self.model.ordered_children_of(&built)?;
3721            let wires = walked_back(&mut self.model, &wires, self.tol)?;
3722            let rebuilt = make_face_on(&mut self.model, surface_id, &wires, self.tol)?.shape;
3723            let turned = rebuilt.oriented(face.orientation());
3724            self.faces.insert(fid, turned.clone());
3725            read[i].1 = turned;
3726        }
3727        self.report.warnings.push(format!(
3728            "#{shell_id}: {} face(s) turned against their surfaces walked their \
3729             loops backward; their loops were walked back",
3730            walk_back.len()
3731        ));
3732        Ok(())
3733    }
3734
3735    // --- product structure, names, colours -----------------------------------
3736
3737    /// Every `GEOMETRICALLY_BOUNDED_WIREFRAME_SHAPE_REPRESENTATION` and
3738    /// `GEOMETRICALLY_BOUNDED_SURFACE_SHAPE_REPRESENTATION`, in file order,
3739    /// with the construction geometry it holds: the points, bounded curves
3740    /// and bounded surfaces of its geometric sets, and the axis placements
3741    /// that are items of its own. An element the reader cannot read is a
3742    /// warning, and the rest of the set still reads.
3743    fn construction_sets(&mut self) -> OgeomResult<ConstructionSets> {
3744        let mut reps: Vec<u64> = self
3745            .instances
3746            .iter()
3747            .filter(|(_, inst)| {
3748                inst.part("GEOMETRICALLY_BOUNDED_WIREFRAME_SHAPE_REPRESENTATION")
3749                    .is_some()
3750                    || inst
3751                        .part("GEOMETRICALLY_BOUNDED_SURFACE_SHAPE_REPRESENTATION")
3752                        .is_some()
3753            })
3754            .map(|(id, _)| id)
3755            .collect();
3756        reps.sort_unstable();
3757        let mut out = Vec::new();
3758        for rep in reps {
3759            let items: Vec<u64> = {
3760                let Some(inst) = self.instances.get(rep) else {
3761                    continue;
3762                };
3763                let args = inst
3764                    .part("GEOMETRICALLY_BOUNDED_WIREFRAME_SHAPE_REPRESENTATION")
3765                    .or_else(|| inst.part("GEOMETRICALLY_BOUNDED_SURFACE_SHAPE_REPRESENTATION"))
3766                    .unwrap_or(&[]);
3767                args.get(1)
3768                    .and_then(Arg::list)
3769                    .unwrap_or(&[])
3770                    .iter()
3771                    .filter_map(Arg::reference)
3772                    .collect()
3773            };
3774            let mut read = Vec::new();
3775            for item in items {
3776                ogeom_core::progress::checkpoint()?;
3777                let Some(inst) = self.instances.get(item) else {
3778                    continue;
3779                };
3780                let set = inst
3781                    .part("GEOMETRIC_CURVE_SET")
3782                    .or_else(|| inst.part("GEOMETRIC_SET"));
3783                if let Some(args) = set {
3784                    let elements: Vec<u64> = args
3785                        .get(1)
3786                        .and_then(Arg::list)
3787                        .unwrap_or(&[])
3788                        .iter()
3789                        .filter_map(Arg::reference)
3790                        .collect();
3791                    self.visited.mark(item);
3792                    for element in elements {
3793                        if let Some(found) = self.construction_item(element) {
3794                            read.push(found);
3795                        }
3796                    }
3797                } else if inst.part("AXIS2_PLACEMENT_3D").is_some() {
3798                    let name = item_name(inst);
3799                    match self.frame(item) {
3800                        Ok(frame) => read.push((name, ConstructionGeometry::Axis(frame))),
3801                        Err(e) => self
3802                            .report
3803                            .warnings
3804                            .push(format!("#{item}: a construction axis is not read: {e}")),
3805                    }
3806                }
3807            }
3808            if !read.is_empty() {
3809                self.visited.mark(rep);
3810                out.push((rep, read));
3811            }
3812        }
3813        Ok(out)
3814    }
3815
3816    /// One element of a geometric set, named: a Cartesian point, a bounded
3817    /// curve over its stretch (a trimmed curve's between its trims), or a
3818    /// bounded surface over its window (a rectangularly trimmed one's in
3819    /// its basis's parameters). `None`, with a warning, for what does not
3820    /// read or is not bounded.
3821    fn construction_item(&mut self, id: u64) -> Option<(String, ConstructionGeometry)> {
3822        let inst = self.instances.get(id)?;
3823        let name = item_name(inst);
3824        let keyword = inst.keyword().to_owned();
3825        let surface = inst.parts.iter().any(|(k, _)| {
3826            k == "PLANE"
3827                || k.ends_with("_SURFACE")
3828                || (k.starts_with("SURFACE_") && !k.ends_with("_CURVE"))
3829        });
3830        let bounded = |(lo, hi): (f64, f64)| {
3831            lo.is_finite() && hi.is_finite() && lo < hi && hi - lo <= SURFACE_EXTENT
3832        };
3833        let read: OgeomResult<Option<ConstructionGeometry>> =
3834            if inst.part("CARTESIAN_POINT").is_some() {
3835                self.point(id).map(|p| Some(ConstructionGeometry::Point(p)))
3836            } else if surface {
3837                self.construction_surface(id, &keyword).map(|found| {
3838                    found
3839                        .filter(|(_, u, v)| bounded(*u) && bounded(*v))
3840                        .map(|(surface, u, v)| ConstructionGeometry::Surface { surface, u, v })
3841                })
3842            } else if keyword == "TRIMMED_CURVE" {
3843                self.construction_trim(id).map(|found| {
3844                    found.map(|(curve, range)| ConstructionGeometry::Curve { curve, range })
3845                })
3846            } else {
3847                self.curve(id).map(|found| {
3848                    found
3849                        .map(|curve| (curve.domain(), curve))
3850                        .filter(|(range, _)| bounded(*range))
3851                        .map(|(range, curve)| ConstructionGeometry::Curve { curve, range })
3852                })
3853            };
3854        match read {
3855            Ok(Some(geometry)) => Some((name, geometry)),
3856            Ok(None) => {
3857                self.report.warnings.push(format!(
3858                    "#{id}: a geometric set's {keyword} is not read: it is not bounded, \
3859                     or not a kind read yet"
3860                ));
3861                None
3862            }
3863            Err(e) => {
3864                self.report.warnings.push(format!(
3865                    "#{id}: a geometric set's {keyword} is not read: {e}"
3866                ));
3867                None
3868            }
3869        }
3870    }
3871
3872    /// A trimmed curve of a geometric set over its stretch: between its
3873    /// trims' parameters where the file names the parameters its master
3874    /// trim and the basis is one this kernel parameterizes as the file
3875    /// does (a line, a conic of its own frame, a spline), running forward;
3876    /// else as [`Self::bounded_curve`] reads it, between its trims' points.
3877    fn construction_trim(&mut self, id: u64) -> OgeomResult<Option<(Curve, (f64, f64))>> {
3878        let args = self.args(id, "TRIMMED_CURVE")?;
3879        let basis_id = args.get(1).and_then(Arg::reference).unwrap_or(0);
3880        let forward = !args.get(4).is_some_and(|a| a.is_enum("F"));
3881        let by_parameter = args.get(5).is_some_and(|a| a.is_enum("PARAMETER"));
3882        let parameter = |trim: Option<&Arg>| {
3883            trim.and_then(Arg::list)?
3884                .iter()
3885                .find_map(|item| match item {
3886                    Arg::Typed(typed) if typed.0 == "PARAMETER_VALUE" => {
3887                        typed.1.first().and_then(Arg::number)
3888                    }
3889                    _ => None,
3890                })
3891        };
3892        let (Some(v0), Some(v1)) = (parameter(args.get(2)), parameter(args.get(3))) else {
3893            return self.bounded_curve(id);
3894        };
3895        if !forward || !by_parameter {
3896            return self.bounded_curve(id);
3897        }
3898        let basis_keyword = self.instance(basis_id)?.keyword().to_owned();
3899        let scale = match basis_keyword.as_str() {
3900            "LINE" => {
3901                let line = self.args(basis_id, "LINE")?;
3902                let vector = self.args(ref_at(line, 2), "VECTOR")?;
3903                vector.get(2).and_then(Arg::number).unwrap_or(1.0) * self.report.scale_mm
3904            }
3905            "CIRCLE" | "ELLIPSE" => self.angle_scale,
3906            "B_SPLINE_CURVE_WITH_KNOTS" => 1.0,
3907            _ if self
3908                .instance(basis_id)?
3909                .part("B_SPLINE_CURVE_WITH_KNOTS")
3910                .is_some() =>
3911            {
3912                1.0
3913            }
3914            _ => return self.bounded_curve(id),
3915        };
3916        let Some(basis) = self.curve(basis_id)? else {
3917            return Ok(None);
3918        };
3919        let (a, mut b) = (v0 * scale, v1 * scale);
3920        if basis.is_periodic() && b <= a {
3921            let (lo, hi) = basis.domain();
3922            b += hi - lo;
3923        }
3924        if a >= b {
3925            return self.bounded_curve(id);
3926        }
3927        Ok(Some((basis, (a, b))))
3928    }
3929
3930    /// A surface of a geometric set with its window: a rectangularly
3931    /// trimmed surface's basis over the trim's parameters, read in the
3932    /// file's units for the basis's kind; any other surface over its own
3933    /// domain.
3934    #[allow(clippy::type_complexity)]
3935    fn construction_surface(
3936        &mut self,
3937        id: u64,
3938        keyword: &str,
3939    ) -> OgeomResult<Option<(SurfaceGeometry, (f64, f64), (f64, f64))>> {
3940        if keyword != "RECTANGULAR_TRIMMED_SURFACE" {
3941            return Ok(self.surface(id)?.map(|surface| {
3942                let (u, v) = surface.domain();
3943                (surface, u, v)
3944            }));
3945        }
3946        let args = self.args(id, "RECTANGULAR_TRIMMED_SURFACE")?;
3947        let basis_id = args.get(1).and_then(Arg::reference).unwrap_or(0);
3948        let Some(basis) = self.surface(basis_id)? else {
3949            return Ok(None);
3950        };
3951        let (length, angle) = (self.report.scale_mm, self.angle_scale);
3952        // How the file measures each parameter of the basis: a plane's in
3953        // lengths, an angle round each axis of revolution, a spline's and a
3954        // swept curve's own parameter as it is.
3955        let (u_scale, v_scale) = match self.instance(basis_id)?.keyword() {
3956            "PLANE" => (length, length),
3957            "CYLINDRICAL_SURFACE" | "CONICAL_SURFACE" => (angle, length),
3958            "SPHERICAL_SURFACE" | "TOROIDAL_SURFACE" | "DEGENERATE_TOROIDAL_SURFACE" => {
3959                (angle, angle)
3960            }
3961            "SURFACE_OF_LINEAR_EXTRUSION" => (1.0, length),
3962            "SURFACE_OF_REVOLUTION" => (1.0, angle),
3963            _ => (1.0, 1.0),
3964        };
3965        let n = |i: usize| args.get(i).and_then(Arg::number).unwrap_or(0.0);
3966        let (u1, u2) = (n(2) * u_scale, n(3) * u_scale);
3967        let (v1, v2) = (n(4) * v_scale, n(5) * v_scale);
3968        Ok(Some((
3969            basis,
3970            (u1.min(u2), u1.max(u2)),
3971            (v1.min(v2), v1.max(v2)),
3972        )))
3973    }
3974
3975    /// Construction items into the document under `product`; one the
3976    /// document refuses is a warning.
3977    fn add_construction(
3978        &mut self,
3979        document: &mut ogeom_doc::Document,
3980        product: ogeom_doc::ProductId,
3981        items: Vec<(String, ConstructionGeometry)>,
3982    ) {
3983        for (name, geometry) in items {
3984            if let Err(e) = document.add_construction(product, &name, geometry) {
3985                self.report
3986                    .warnings
3987                    .push(format!("construction item '{name}' is not kept: {e}"));
3988            }
3989        }
3990    }
3991
3992    /// Assemble the document: the model, plus everything the file says about
3993    /// products, assemblies, placements and appearance.
3994    ///
3995    /// Takes the model out of the reader; geometry reading is over by the
3996    /// time structure is read. Structure that resists becomes a warning and a
3997    /// flat document, never an error: the geometry is already good, and a
3998    /// mangled product tree should not take it down.
3999    fn document(
4000        &mut self,
4001        by_item: &FastMap<u64, Shape>,
4002        solids: &[Shape],
4003        shells: &[Shape],
4004        construction: ConstructionSets,
4005    ) -> OgeomResult<ogeom_doc::Document> {
4006        // The graph is walked before the model moves, because frames scale
4007        // through the reader's own unit handling.
4008        let mut sets: FastMap<u64, Vec<(String, ConstructionGeometry)>> = FastMap::default();
4009        let order: Vec<u64> = construction.iter().map(|(rep, _)| *rep).collect();
4010        sets.extend(construction);
4011        let structure = self.product_structure(by_item, &mut sets);
4012        let colours = self.colours(by_item);
4013        let pmi = self.pmi_of();
4014
4015        let mut document = ogeom_doc::Document::over(std::mem::take(&mut self.model));
4016        match structure {
4017            Some(products) => self.build_products(&mut document, products),
4018            None => {
4019                for (i, solid) in solids.iter().enumerate() {
4020                    document.add_part(format!("solid-{i}"), solid.clone());
4021                }
4022                for (i, shell) in shells.iter().enumerate() {
4023                    document.add_part(format!("shell-{i}"), shell.clone());
4024                }
4025            }
4026        }
4027        // A set no product reaches is a part of its own, named as its
4028        // representation is.
4029        for (i, rep) in order.into_iter().enumerate() {
4030            let Some(items) = sets.remove(&rep) else {
4031                continue;
4032            };
4033            let name = self
4034                .instances
4035                .get(rep)
4036                .and_then(|inst| inst.parts.first())
4037                .and_then(|(_, args)| match args.first() {
4038                    Some(Arg::Str(name)) if !name.is_empty() => Some(name.clone()),
4039                    _ => None,
4040                })
4041                .unwrap_or_else(|| format!("construction-{i}"));
4042            let empty = ogeom_algo::build::make_compound(document.model_mut(), &[])?.shape;
4043            let part = document.add_part(name, empty);
4044            self.add_construction(&mut document, part, items);
4045        }
4046        for (shape, colour) in colours {
4047            document.set_colour(&shape, colour);
4048        }
4049        *document.pmi_mut() = pmi;
4050        for view in self.views() {
4051            document.add_view(view);
4052        }
4053        Ok(document)
4054    }
4055
4056    /// The file's product graph, or `None` when it has none worth the name.
4057    fn product_structure(
4058        &mut self,
4059        by_item: &FastMap<u64, Shape>,
4060        sets: &mut FastMap<u64, Vec<(String, ConstructionGeometry)>>,
4061    ) -> Option<Vec<PdEntry>> {
4062        // PRODUCT_DEFINITION -> name, via formation and product.
4063        let mut pds: Vec<u64> = self
4064            .instances
4065            .iter()
4066            .filter(|(_, inst)| inst.part("PRODUCT_DEFINITION").is_some())
4067            .filter(|(_, inst)| inst.part("PRODUCT_DEFINITION_RELATIONSHIP").is_none())
4068            .map(|(id, _)| id)
4069            .collect();
4070        pds.sort_unstable();
4071        if pds.is_empty() {
4072            return None;
4073        }
4074
4075        // SHAPE_DEFINITION_REPRESENTATION: definition (a PRODUCT_DEFINITION_SHAPE
4076        // over a PD or a usage) -> shape representation.
4077        let mut sr_of_pd: FastMap<u64, u64> = FastMap::default();
4078        let sdrs: Vec<(u64, u64)> = self
4079            .instances
4080            .iter()
4081            .filter(|(_, inst)| inst.part("SHAPE_DEFINITION_REPRESENTATION").is_some())
4082            .filter_map(|(id, _)| {
4083                let args = self.args(id, "SHAPE_DEFINITION_REPRESENTATION").ok()?;
4084                Some((args.first()?.reference()?, args.get(1)?.reference()?))
4085            })
4086            .collect();
4087        for (pds_id, sr) in sdrs {
4088            if let Some(definition) = self.definition_of_shape(pds_id) {
4089                sr_of_pd.insert(definition, sr);
4090            }
4091        }
4092
4093        // A product's solids may live one representation over: AP203 files
4094        // routinely tie the product to a bare axis representation and hang
4095        // the B-rep off it through a plain SHAPE_REPRESENTATION_RELATIONSHIP.
4096        // Only the plain ones are followed; the transformation-carrying kind
4097        // is an assembly edge, and following it would leak one product's
4098        // geometry into another.
4099        let mut linked: FastMap<u64, Vec<u64>> = FastMap::default();
4100        let mut srrs: Vec<u64> = self
4101            .instances
4102            .iter()
4103            .filter(|(_, inst)| {
4104                inst.part("SHAPE_REPRESENTATION_RELATIONSHIP").is_some()
4105                    && inst
4106                        .part("REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION")
4107                        .is_none()
4108            })
4109            .map(|(id, _)| id)
4110            .collect();
4111        srrs.sort_unstable();
4112        for srr in srrs {
4113            let args = {
4114                let Ok(instance) = self.instance(srr) else {
4115                    continue;
4116                };
4117                let Some(args) = instance
4118                    .part("SHAPE_REPRESENTATION_RELATIONSHIP")
4119                    .or_else(|| instance.part("REPRESENTATION_RELATIONSHIP"))
4120                else {
4121                    continue;
4122                };
4123                args.to_vec()
4124            };
4125            if let (Some(a), Some(b)) = (
4126                args.get(2).and_then(Arg::reference),
4127                args.get(3).and_then(Arg::reference),
4128            ) {
4129                linked.entry(a).or_default().push(b);
4130                linked.entry(b).or_default().push(a);
4131            }
4132        }
4133
4134        let mut entries = Vec::new();
4135        for pd in pds {
4136            let name = self.product_name(pd).unwrap_or_else(|| format!("#{pd}"));
4137            let mut shapes: Vec<Shape> = Vec::new();
4138            let mut construction = Vec::new();
4139            if let Some(&sr) = sr_of_pd.get(&pd) {
4140                let mut reps = vec![sr];
4141                reps.extend(linked.get(&sr).into_iter().flatten().copied());
4142                for rep in reps {
4143                    if let Some(items) = self.representation_items(rep) {
4144                        shapes.extend(items.iter().filter_map(|item| by_item.get(item).cloned()));
4145                    }
4146                    construction.extend(sets.remove(&rep).into_iter().flatten());
4147                }
4148            }
4149            entries.push(PdEntry {
4150                pd,
4151                name,
4152                shapes,
4153                construction,
4154                children: Vec::new(),
4155            });
4156        }
4157
4158        // NEXT_ASSEMBLY_USAGE_OCCURRENCE: parent -> child, with the placement
4159        // recovered from the CONTEXT_DEPENDENT_SHAPE_REPRESENTATION over it.
4160        let mut nauos: Vec<u64> = self
4161            .instances
4162            .iter()
4163            .filter(|(_, inst)| inst.part("NEXT_ASSEMBLY_USAGE_OCCURRENCE").is_some())
4164            .map(|(id, _)| id)
4165            .collect();
4166        nauos.sort_unstable();
4167        let entry_of_pd: FastMap<u64, usize> =
4168            entries.iter().enumerate().map(|(i, e)| (e.pd, i)).collect();
4169        for nauo in nauos {
4170            let Ok(args) = self.args(nauo, "NEXT_ASSEMBLY_USAGE_OCCURRENCE") else {
4171                continue;
4172            };
4173            let (Some(parent), Some(child)) = (
4174                args.get(3).and_then(Arg::reference),
4175                args.get(4).and_then(Arg::reference),
4176            ) else {
4177                continue;
4178            };
4179            let name = args
4180                .get(5)
4181                .and_then(|a| match a {
4182                    Arg::Str(s) if !s.is_empty() => Some(s.clone()),
4183                    _ => None,
4184                })
4185                .or_else(|| {
4186                    self.args(nauo, "NEXT_ASSEMBLY_USAGE_OCCURRENCE")
4187                        .ok()
4188                        .and_then(|args| match args.get(1) {
4189                            Some(Arg::Str(s)) if !s.is_empty() => Some(s.clone()),
4190                            _ => None,
4191                        })
4192                });
4193            let child_sr = sr_of_pd.get(&child).copied();
4194            let at = self
4195                .usage_transform(nauo, child_sr)
4196                .unwrap_or(Transform::IDENTITY);
4197            if let Some(&at_index) = entry_of_pd.get(&parent) {
4198                entries[at_index].children.push((child, at, name));
4199            }
4200        }
4201        Some(entries)
4202    }
4203
4204    /// The `PRODUCT_DEFINITION` (or usage) a `PRODUCT_DEFINITION_SHAPE` is over.
4205    fn definition_of_shape(&mut self, pds_id: u64) -> Option<u64> {
4206        let args = self.args(pds_id, "PRODUCT_DEFINITION_SHAPE").ok()?;
4207        args.get(2).and_then(Arg::reference)
4208    }
4209
4210    /// A product definition's product name.
4211    fn product_name(&mut self, pd: u64) -> Option<String> {
4212        let formation = self
4213            .args(pd, "PRODUCT_DEFINITION")
4214            .ok()?
4215            .get(2)
4216            .and_then(Arg::reference)?;
4217        // The formation, plain or with its source named (a mesh converter's
4218        // habit, and a modeller's for an assembly's parts) carries the
4219        // product in its third slot either way, whether it stands alone or
4220        // as one part of a complex instance.
4221        let product = [
4222            "PRODUCT_DEFINITION_FORMATION",
4223            "PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE",
4224        ]
4225        .into_iter()
4226        .find_map(|keyword| {
4227            self.instance(formation)
4228                .ok()?
4229                .part(keyword)
4230                .or_else(|| self.args(formation, keyword).ok())?
4231                .get(2)
4232                .and_then(Arg::reference)
4233        })?;
4234        // The product's name, or its id where the name is blank: a mesh
4235        // converter fills the id and leaves the name empty.
4236        let args = self.args(product, "PRODUCT").ok()?;
4237        [args.get(1), args.first()]
4238            .into_iter()
4239            .find_map(|arg| match arg {
4240                Some(Arg::Str(name)) if !name.is_empty() => Some(name.clone()),
4241                _ => None,
4242            })
4243    }
4244
4245    /// A representation's item references.
4246    fn representation_items(&mut self, sr: u64) -> Option<Vec<u64>> {
4247        let instance = self.instance(sr).ok()?;
4248        // Every subtype a file names a shape representation by: the B-rep
4249        // kinds, the surface-model kinds a mesh converter writes, and the
4250        // plain one.
4251        let args = [
4252            "SHAPE_REPRESENTATION",
4253            "ADVANCED_BREP_SHAPE_REPRESENTATION",
4254            "MANIFOLD_SURFACE_SHAPE_REPRESENTATION",
4255            "FACETED_BREP_SHAPE_REPRESENTATION",
4256            "GEOMETRICALLY_BOUNDED_SURFACE_SHAPE_REPRESENTATION",
4257            "GEOMETRICALLY_BOUNDED_WIREFRAME_SHAPE_REPRESENTATION",
4258            "REPRESENTATION",
4259        ]
4260        .into_iter()
4261        .find_map(|keyword| instance.part(keyword))?
4262        .to_vec();
4263        Some(
4264            args.get(1)?
4265                .list()?
4266                .iter()
4267                .filter_map(Arg::reference)
4268                .collect(),
4269        )
4270    }
4271
4272    /// The placement a usage's `CONTEXT_DEPENDENT_SHAPE_REPRESENTATION` states.
4273    ///
4274    /// The transformation aligns an axis placement in the child's space with
4275    /// one in the parent's; which item is which follows from which side of
4276    /// the representation relationship is the child's own shape
4277    /// representation, not from argument order, because real files disagree
4278    /// about the order.
4279    fn usage_transform(&mut self, nauo: u64, child_sr: Option<u64>) -> Option<Transform> {
4280        if self.cdsr_of_nauo.is_none() {
4281            // One pass over the CDSRs, each resolved to the usage it
4282            // describes, in ascending id order, so a usage described twice
4283            // keeps the lowest-id one.
4284            let mut cdsrs: Vec<u64> = self
4285                .instances
4286                .iter()
4287                .filter(|(_, inst)| {
4288                    inst.part("CONTEXT_DEPENDENT_SHAPE_REPRESENTATION")
4289                        .is_some()
4290                })
4291                .map(|(id, _)| id)
4292                .collect();
4293            cdsrs.sort_unstable();
4294            let mut index: FastMap<u64, u64> = FastMap::default();
4295            for id in cdsrs {
4296                let Some(owner) = self
4297                    .args(id, "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION")
4298                    .ok()
4299                    .and_then(|args| args.get(1).and_then(Arg::reference))
4300                    .and_then(|pds| self.definition_of_shape(pds))
4301                else {
4302                    continue;
4303                };
4304                index.entry(owner).or_insert(id);
4305            }
4306            self.cdsr_of_nauo = Some(index);
4307        }
4308        let cdsr = *self.cdsr_of_nauo.as_ref()?.get(&nauo)?;
4309        let rr = self
4310            .args(cdsr, "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION")
4311            .ok()?
4312            .first()
4313            .and_then(Arg::reference)?;
4314        let (rep_1, rep_2) = {
4315            let args = self.args(rr, "REPRESENTATION_RELATIONSHIP").ok()?;
4316            (
4317                args.get(2).and_then(Arg::reference)?,
4318                args.get(3).and_then(Arg::reference)?,
4319            )
4320        };
4321        let idt = self
4322            .args(rr, "REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION")
4323            .ok()?
4324            .first()
4325            .and_then(Arg::reference)?;
4326        let (item_1, item_2) = {
4327            let args = self.args(idt, "ITEM_DEFINED_TRANSFORMATION").ok()?;
4328            (
4329                args.get(2).and_then(Arg::reference)?,
4330                args.get(3).and_then(Arg::reference)?,
4331            )
4332        };
4333        let frame_1 = self.frame(item_1).ok()?;
4334        let frame_2 = self.frame(item_2).ok()?;
4335        // item_1 pairs with rep_1. When rep_1 is the child's representation,
4336        // the child's frame_1 lands on the parent's frame_2.
4337        let child_first = match child_sr {
4338            Some(sr) => rep_1 == sr || rep_2 != sr,
4339            None => true,
4340        };
4341        Some(if child_first {
4342            Transform::from_frame(&frame_2) * Transform::to_frame(&frame_1)
4343        } else {
4344            Transform::from_frame(&frame_1) * Transform::to_frame(&frame_2)
4345        })
4346    }
4347
4348    /// Products into the document: assemblies for the parents, parts for the
4349    /// shaped, instances for the usage edges.
4350    fn build_products(&mut self, document: &mut ogeom_doc::Document, mut entries: Vec<PdEntry>) {
4351        let mut ids: FastMap<u64, ogeom_doc::ProductId> = FastMap::default();
4352        for entry in &mut entries {
4353            let shape = match entry.shapes.len() {
4354                // A product whose content is all construction geometry is
4355                // a part over nothing, carrying it.
4356                0 if !entry.construction.is_empty() && entry.children.is_empty() => {
4357                    match ogeom_algo::build::make_compound(document.model_mut(), &[]) {
4358                        Ok(built) => Some(built.shape),
4359                        Err(_) => None,
4360                    }
4361                }
4362                0 => None,
4363                1 => Some(entry.shapes[0].clone()),
4364                _ => match ogeom_algo::build::make_compound(document.model_mut(), &entry.shapes) {
4365                    Ok(built) => Some(built.shape),
4366                    Err(_) => Some(entry.shapes[0].clone()),
4367                },
4368            };
4369            if entry.children.is_empty() {
4370                if let Some(shape) = shape {
4371                    ids.insert(entry.pd, document.add_part(&entry.name, shape));
4372                }
4373                // A product with neither shape nor children holds nothing a
4374                // document can say; it is left out.
4375            } else {
4376                let assembly = document.add_assembly(&entry.name);
4377                ids.insert(entry.pd, assembly);
4378                // An assembly with its own geometry keeps it as a body part
4379                // placed at identity: rare, but files do it.
4380                if let Some(shape) = shape {
4381                    let body = document.add_part(format!("{}-body", entry.name), shape);
4382                    let _ = document.add_instance(assembly, body, Transform::IDENTITY, None);
4383                }
4384            }
4385            if let Some(&id) = ids.get(&entry.pd) {
4386                let items = std::mem::take(&mut entry.construction);
4387                self.add_construction(document, id, items);
4388            }
4389        }
4390        for entry in &entries {
4391            let Some(&parent) = ids.get(&entry.pd) else {
4392                continue;
4393            };
4394            for (child, at, name) in &entry.children {
4395                let Some(&child_id) = ids.get(child) else {
4396                    self.report.warnings.push(format!(
4397                        "#{child}: an assembly child holds nothing readable"
4398                    ));
4399                    continue;
4400                };
4401                if let Err(e) = document.add_instance(parent, child_id, *at, name.clone()) {
4402                    self.report
4403                        .warnings
4404                        .push(format!("assembly edge #{} -> #{child}: {e}", entry.pd));
4405                }
4406            }
4407        }
4408    }
4409
4410    /// Colours from styled items, keyed to the shapes they style.
4411    fn colours(&mut self, by_item: &FastMap<u64, Shape>) -> Vec<(Shape, ogeom_doc::Colour)> {
4412        let mut styled: Vec<u64> = self
4413            .instances
4414            .iter()
4415            .filter(|(_, inst)| {
4416                inst.part("STYLED_ITEM").is_some() || inst.part("OVER_RIDING_STYLED_ITEM").is_some()
4417            })
4418            .map(|(id, _)| id)
4419            .collect();
4420        // Sorted so an item styled twice resolves the same way every run;
4421        // overriding styles carry higher instance numbers in practice, and a
4422        // later same-shape entry wins the map insertion downstream.
4423        styled.sort_unstable();
4424        let mut out = Vec::new();
4425        for id in styled {
4426            let args = {
4427                let Ok(instance) = self.instance(id) else {
4428                    continue;
4429                };
4430                let Some(args) = instance
4431                    .part("STYLED_ITEM")
4432                    .or_else(|| instance.part("OVER_RIDING_STYLED_ITEM"))
4433                else {
4434                    continue;
4435                };
4436                args.to_vec()
4437            };
4438            let Some(item) = args.get(2).and_then(Arg::reference) else {
4439                continue;
4440            };
4441            let shapes = match by_item.get(&item).or_else(|| self.faces.get(&item)) {
4442                Some(shape) => vec![shape.clone()],
4443                None => self.edge_shapes(item),
4444            };
4445            if shapes.is_empty() {
4446                continue;
4447            }
4448            let styles: Vec<u64> = args
4449                .get(1)
4450                .and_then(Arg::list)
4451                .map(|list| list.iter().filter_map(Arg::reference).collect())
4452                .unwrap_or_default();
4453            if let Some(colour) = styles.iter().find_map(|&style| self.colour_in(style, 0)) {
4454                out.extend(shapes.into_iter().map(|shape| (shape, colour)));
4455            }
4456        }
4457        out
4458    }
4459
4460    /// The first `COLOUR_RGB` reachable from a presentation style, depth-bounded.
4461    ///
4462    /// The styled-item chain has five links and real files rearrange them, so
4463    /// the walk follows references rather than the textbook path.
4464    fn colour_in(&mut self, id: u64, depth: usize) -> Option<ogeom_doc::Colour> {
4465        if depth > 6 {
4466            return None;
4467        }
4468        let (keyword, args) = {
4469            let instance = self.instance(id).ok()?;
4470            let all: Vec<Arg> = instance
4471                .parts()
4472                .flat_map(|(_, args)| args.iter().cloned())
4473                .collect();
4474            (instance.keyword().to_owned(), all)
4475        };
4476        if keyword == "COLOUR_RGB" {
4477            let channel = |i: usize| args.get(i).and_then(Arg::number);
4478            return Some(ogeom_doc::Colour::rgb(
4479                channel(1)?,
4480                channel(2)?,
4481                channel(3)?,
4482            ));
4483        }
4484        // A colour named rather than mixed: one of the eight the standard
4485        // (ISO 10303-46) defines, each channel fully on or off, read in any
4486        // case and with any spaces around it. Any other name is no colour,
4487        // said once for the entity.
4488        if keyword == "DRAUGHTING_PRE_DEFINED_COLOUR" {
4489            let name = match args.first() {
4490                Some(Arg::Str(name)) => name.as_str(),
4491                _ => "",
4492            };
4493            let channels = match name.trim().to_ascii_lowercase().as_str() {
4494                "black" => Some((0.0, 0.0, 0.0)),
4495                "red" => Some((1.0, 0.0, 0.0)),
4496                "green" => Some((0.0, 1.0, 0.0)),
4497                "blue" => Some((0.0, 0.0, 1.0)),
4498                "yellow" => Some((1.0, 1.0, 0.0)),
4499                "magenta" => Some((1.0, 0.0, 1.0)),
4500                "cyan" => Some((0.0, 1.0, 1.0)),
4501                "white" => Some((1.0, 1.0, 1.0)),
4502                _ => None,
4503            };
4504            if channels.is_none() && self.unnamed_colours.insert(id) {
4505                self.report.warnings.push(format!(
4506                    "#{id}: DRAUGHTING_PRE_DEFINED_COLOUR '{name}' names none of the eight \
4507                     predefined colours; the item is left uncoloured"
4508                ));
4509            }
4510            return channels.map(|(r, g, b)| ogeom_doc::Colour::rgb(r, g, b));
4511        }
4512        let mut refs: Vec<u64> = Vec::new();
4513        collect_refs(&args, &mut refs);
4514        refs.into_iter()
4515            .find_map(|next| self.colour_in(next, depth + 1))
4516    }
4517
4518    // --- semantic PMI --------------------------------------------------------
4519
4520    /// The file's semantic PMI: dimensions, geometric tolerances, datums.
4521    ///
4522    /// Annotations that resist stay out with a warning; PMI never takes the
4523    /// geometry down.
4524    fn pmi_of(&mut self) -> ogeom_doc::Pmi {
4525        let mut pmi = ogeom_doc::Pmi::new();
4526        // Which STEP instance each annotation came from, so the presentation
4527        // pass can name the annotation a callout draws exactly rather than by
4528        // matching a string two annotations may share.
4529        let mut annotation_ids: FastMap<u64, ogeom_doc::Annotated> = FastMap::default();
4530
4531        // Which topology each shape aspect describes: directly through
4532        // GEOMETRIC_ITEM_SPECIFIC_USAGE, and one relationship step outward,
4533        // because composite aspects hold their pieces through relationships.
4534        let mut aspect_items: FastMap<u64, Vec<ogeom_topo::TShapeId>> = FastMap::default();
4535        let mut gisus = self.ids_with("GEOMETRIC_ITEM_SPECIFIC_USAGE");
4536        gisus.sort_unstable();
4537        for id in gisus {
4538            let Ok(args) = self.args(id, "GEOMETRIC_ITEM_SPECIFIC_USAGE") else {
4539                continue;
4540            };
4541            let (Some(aspect), Some(item)) = (
4542                args.get(2).and_then(Arg::reference),
4543                args.get(4).and_then(Arg::reference),
4544            ) else {
4545                continue;
4546            };
4547            let nodes = match self.faces.get(&item) {
4548                Some(face) => vec![face.node()],
4549                None => self.edge_shapes(item).iter().map(Shape::node).collect(),
4550            };
4551            if !nodes.is_empty() {
4552                aspect_items.entry(aspect).or_default().extend(nodes);
4553            }
4554        }
4555        let mut adjacency: FastMap<u64, Vec<u64>> = FastMap::default();
4556        for id in self.ids_with("SHAPE_ASPECT_RELATIONSHIP") {
4557            let Ok(args) = self.args(id, "SHAPE_ASPECT_RELATIONSHIP") else {
4558                continue;
4559            };
4560            if let (Some(a), Some(b)) = (
4561                args.get(2).and_then(Arg::reference),
4562                args.get(3).and_then(Arg::reference),
4563            ) {
4564                adjacency.entry(a).or_default().push(b);
4565                adjacency.entry(b).or_default().push(a);
4566            }
4567        }
4568        let items_for = |aspect: u64| -> Vec<ogeom_topo::TShapeId> {
4569            // Three relationship steps: a composite aspect holds components,
4570            // a derived aspect sits behind a composite, and a datum one link
4571            // behind its features: the deepest chain real files exhibit.
4572            let mut reach = vec![aspect];
4573            for _ in 0..3 {
4574                let mut next = reach.clone();
4575                for a in &reach {
4576                    next.extend(adjacency.get(a).into_iter().flatten().copied());
4577                }
4578                next.sort_unstable();
4579                next.dedup();
4580                reach = next;
4581            }
4582            let mut out: Vec<ogeom_topo::TShapeId> = Vec::new();
4583            for a in reach {
4584                out.extend(aspect_items.get(&a).into_iter().flatten().copied());
4585            }
4586            out.sort_unstable();
4587            out.dedup();
4588            out
4589        };
4590
4591        // Dimensions: characteristic -> representation, values from the
4592        // measure items, bounds from any plus/minus tolerance over the same
4593        // characteristic.
4594        let mut plus_minus: FastMap<u64, (Option<f64>, Option<f64>)> = FastMap::default();
4595        for id in self.ids_with("PLUS_MINUS_TOLERANCE") {
4596            let Ok(args) = self.args(id, "PLUS_MINUS_TOLERANCE") else {
4597                continue;
4598            };
4599            let (Some(tv), Some(dim)) = (
4600                args.first().and_then(Arg::reference),
4601                args.get(1).and_then(Arg::reference),
4602            ) else {
4603                continue;
4604            };
4605            let Ok(tv_args) = self.args(tv, "TOLERANCE_VALUE") else {
4606                continue;
4607            };
4608            let lower = tv_args
4609                .first()
4610                .and_then(Arg::reference)
4611                .and_then(|r| self.measure_value(r))
4612                .map(|(v, _)| v);
4613            let upper = tv_args
4614                .get(1)
4615                .and_then(Arg::reference)
4616                .and_then(|r| self.measure_value(r))
4617                .map(|(v, _)| v);
4618            plus_minus.insert(dim, (lower, upper));
4619        }
4620        let mut dcrs = self.ids_with("DIMENSIONAL_CHARACTERISTIC_REPRESENTATION");
4621        dcrs.sort_unstable();
4622        for id in dcrs {
4623            let Ok(args) = self.args(id, "DIMENSIONAL_CHARACTERISTIC_REPRESENTATION") else {
4624                continue;
4625            };
4626            let (Some(dim), Some(sdr)) = (
4627                args.first().and_then(Arg::reference),
4628                args.get(1).and_then(Arg::reference),
4629            ) else {
4630                continue;
4631            };
4632            let mut values = Vec::new();
4633            let mut kind = ogeom_doc::MeasureKind::Length;
4634            if let Ok(sdr_args) = self.args(sdr, "SHAPE_DIMENSION_REPRESENTATION") {
4635                for item in sdr_args.get(1).and_then(Arg::list).unwrap_or(&[]) {
4636                    if let Some(r) = item.reference()
4637                        && let Some((v, k)) = self.measure_value(r)
4638                    {
4639                        values.push(v);
4640                        kind = k;
4641                    }
4642                }
4643            }
4644            let (name, location, aspects) = self.dimension_shape(dim);
4645            let features: Vec<Vec<ogeom_topo::TShapeId>> =
4646                aspects.into_iter().map(&items_for).collect();
4647            let (minus, plus) = plus_minus.get(&dim).copied().unwrap_or((None, None));
4648            annotation_ids.insert(dim, ogeom_doc::Annotated::Dimension(pmi.dimensions.len()));
4649            pmi.dimensions.push(ogeom_doc::Dimension {
4650                name,
4651                values,
4652                kind,
4653                plus,
4654                minus,
4655                features,
4656                location,
4657            });
4658        }
4659
4660        // Geometric tolerances: any instance whose subtype names one. The
4661        // complex form keeps its attributes on the GEOMETRIC_TOLERANCE part;
4662        // the simple form flattens them into the subtype's own list.
4663        let is_subtype = |k: &str| {
4664            k.ends_with("_TOLERANCE")
4665                && !matches!(
4666                    k,
4667                    "GEOMETRIC_TOLERANCE"
4668                        | "GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE"
4669                        | "GEOMETRIC_TOLERANCE_WITH_DEFINED_UNIT"
4670                        | "GEOMETRIC_TOLERANCE_WITH_MODIFIERS"
4671                        | "GEOMETRIC_TOLERANCE_WITH_MAXIMUM_TOLERANCE"
4672                        | "PLUS_MINUS_TOLERANCE"
4673                        | "TOLERANCE_VALUE"
4674                )
4675        };
4676        let mut gts: Vec<u64> = self
4677            .instances
4678            .iter()
4679            .filter(|(_, inst)| inst.parts().any(|(k, _)| is_subtype(k)))
4680            .map(|(id, _)| id)
4681            .collect();
4682        gts.sort_unstable();
4683        for id in gts {
4684            let (name, magnitude_ref, aspect, kind, modifiers, datum_refs) = {
4685                let Ok(instance) = self.instance(id) else {
4686                    continue;
4687                };
4688                let subtype = instance
4689                    .parts()
4690                    .map(|(k, _)| k.to_owned())
4691                    .find(|k| is_subtype(k));
4692                let Some(subtype) = subtype else {
4693                    continue;
4694                };
4695                let base = instance
4696                    .part("GEOMETRIC_TOLERANCE")
4697                    .or_else(|| instance.part(&subtype))
4698                    .unwrap_or(&[])
4699                    .to_vec();
4700                let name = match base.first() {
4701                    Some(Arg::Str(s)) => s.clone(),
4702                    _ => String::new(),
4703                };
4704                let magnitude_ref = base.get(2).and_then(Arg::reference);
4705                let aspect = base.get(3).and_then(Arg::reference);
4706                let kind = Some(subtype.trim_end_matches("_TOLERANCE").to_lowercase());
4707                // Modifiers ride on their own part in the complex form, as
4708                // a list of enumeration words.
4709                let modifiers: Vec<String> = instance
4710                    .part("GEOMETRIC_TOLERANCE_WITH_MODIFIERS")
4711                    .and_then(|args| args.first())
4712                    .and_then(Arg::list)
4713                    .map(|list| {
4714                        list.iter()
4715                            .filter_map(|arg| match arg {
4716                                Arg::Enum(word) => Some(word.to_lowercase()),
4717                                _ => None,
4718                            })
4719                            .collect()
4720                    })
4721                    .unwrap_or_default();
4722                // The complex form keeps the datum list on its own part;
4723                // the simple form appends it as the subtype's fifth argument.
4724                let datum_refs: Vec<u64> = instance
4725                    .part("GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE")
4726                    .and_then(|args| args.first())
4727                    .and_then(Arg::list)
4728                    .or_else(|| base.get(4).and_then(Arg::list))
4729                    .map(|list| list.iter().filter_map(Arg::reference).collect())
4730                    .unwrap_or_default();
4731                (name, magnitude_ref, aspect, kind, modifiers, datum_refs)
4732            };
4733            let Some(kind) = kind else {
4734                continue;
4735            };
4736            let magnitude = magnitude_ref
4737                .and_then(|r| self.measure_value(r))
4738                .map_or(0.0, |(v, _)| v);
4739            // A datum system states the whole frame, in precedence order
4740            // and with each reference's modifiers; any other reference is
4741            // one datum, or one common datum.
4742            let mut datums: Vec<ogeom_doc::DatumReference> = Vec::new();
4743            let mut datum_system = false;
4744            for &r in &datum_refs {
4745                if let Some(frame) = self.datum_system(r) {
4746                    datums.extend(frame);
4747                    datum_system = true;
4748                } else if let Some(letter) = self.datum_letter(r, 0) {
4749                    datums.push(letter.into());
4750                }
4751            }
4752            let items = aspect.map(items_for).unwrap_or_default();
4753            annotation_ids.insert(id, ogeom_doc::Annotated::Tolerance(pmi.tolerances.len()));
4754            pmi.tolerances.push(ogeom_doc::GeometricTolerance {
4755                kind,
4756                name,
4757                magnitude,
4758                modifiers,
4759                datums,
4760                datum_system,
4761                items,
4762            });
4763        }
4764
4765        // Datums: the letters, with the features they mark reached through
4766        // the aspect graph.
4767        let mut datums = self.ids_with("DATUM");
4768        datums.sort_unstable();
4769        for id in datums {
4770            let letter = {
4771                let Ok(instance) = self.instance(id) else {
4772                    continue;
4773                };
4774                if instance.part("DATUM_FEATURE").is_some()
4775                    || instance.part("DATUM_REFERENCE").is_some()
4776                    || instance.part("DATUM_REFERENCE_COMPARTMENT").is_some()
4777                    || instance.part("DATUM_SYSTEM").is_some()
4778                {
4779                    continue;
4780                }
4781                let Some(args) = instance.part("DATUM") else {
4782                    continue;
4783                };
4784                match args.get(4) {
4785                    Some(Arg::Str(s)) if !s.is_empty() => s.clone(),
4786                    _ => continue,
4787                }
4788            };
4789            annotation_ids.insert(id, ogeom_doc::Annotated::Datum(pmi.datums.len()));
4790            pmi.datums.push(ogeom_doc::Datum {
4791                label: letter,
4792                items: items_for(id),
4793            });
4794        }
4795
4796        // Datum targets: the pads a datum is actually established at. The
4797        // target's identifier is the letter's number (`A1` is target 1 of
4798        // datum A), and its placement and size come through the shape
4799        // representation the feature is associated with.
4800        let mut targets = self.ids_with("PLACED_DATUM_TARGET_FEATURE");
4801        targets.sort_unstable();
4802        for id in targets {
4803            if let Some(target) = self.datum_target(id, &items_for) {
4804                pmi.targets.push(target);
4805            }
4806        }
4807
4808        // Presentation: what a viewer draws. A callout holds tessellated
4809        // annotation occurrences, each an indexed set of polylines over a
4810        // coordinates list; an annotation plane says which plane they are
4811        // drawn in and which callouts it holds; and a model item association
4812        // says which semantic annotation a callout is the picture of.
4813        let (callouts, callout_index) = self.callouts(&annotation_ids, &aspect_items);
4814        pmi.callouts = callouts;
4815        self.callout_index = callout_index;
4816        pmi
4817    }
4818
4819    /// Saved views: every *named* draughting model is one; the unnamed one
4820    /// is the annotation-plane container this writer emits itself. The
4821    /// camera item gives the frame; the callout items give the subset.
4822    fn views(&mut self) -> Vec<ogeom_doc::View> {
4823        let mut out = Vec::new();
4824        for id in self.ids_with("DRAUGHTING_MODEL") {
4825            let Ok(args) = self.args(id, "DRAUGHTING_MODEL") else {
4826                continue;
4827            };
4828            let name = match args.first() {
4829                Some(Arg::Str(text)) => text.clone(),
4830                _ => String::new(),
4831            };
4832            if name.is_empty() {
4833                continue;
4834            }
4835            let mut frame = None;
4836            let mut callouts = Vec::new();
4837            for item in args.get(1).and_then(Arg::list).unwrap_or(&[]) {
4838                let Some(item) = item.reference() else {
4839                    continue;
4840                };
4841                if let Ok(cam) = self.args(item, "CAMERA_MODEL_D3") {
4842                    if let Some(placement) = cam.get(1).and_then(Arg::reference) {
4843                        frame = self.frame(placement).ok();
4844                    }
4845                    continue;
4846                }
4847                if let Some(&index) = self.callout_index.get(&item) {
4848                    callouts.push(index);
4849                }
4850            }
4851            let Some(frame) = frame else { continue };
4852            out.push(ogeom_doc::View {
4853                name,
4854                frame,
4855                clipping: None,
4856                callouts,
4857            });
4858        }
4859        out
4860    }
4861
4862    /// One placed datum target: which datum, which number, where and how big.
4863    fn datum_target(
4864        &mut self,
4865        id: u64,
4866        items_for: &dyn Fn(u64) -> Vec<ogeom_topo::TShapeId>,
4867    ) -> Option<ogeom_doc::DatumTarget> {
4868        let (target_id, description) = {
4869            let instance = self.instance(id).ok()?;
4870            let args = instance.part("PLACED_DATUM_TARGET_FEATURE")?;
4871            let target = match args.get(4) {
4872                Some(Arg::Str(s)) if !s.is_empty() => s.clone(),
4873                _ => return None,
4874            };
4875            let description = match args.get(1) {
4876                Some(Arg::Str(s)) => s.to_ascii_lowercase(),
4877                _ => String::new(),
4878            };
4879            (target, description)
4880        };
4881        // `A1`: the letter is the datum, the digits its number.
4882        let split = target_id
4883            .find(|c: char| c.is_ascii_digit())
4884            .unwrap_or(target_id.len());
4885        let (letter, number) = target_id.split_at(split);
4886        let index = number.parse::<u32>().unwrap_or(1);
4887        let datum = if letter.is_empty() {
4888            self.datum_letter(id, 0).unwrap_or_default()
4889        } else {
4890            letter.to_owned()
4891        };
4892
4893        // The target's placement and size live in a shape representation the
4894        // target's own property definition names. The lengths come in the
4895        // file's own unit, as every length does. Both hops go through the
4896        // indexes rather than a scan of every entity per property *per
4897        // target*.
4898        self.ensure_property_indexes();
4899        let mut frame = None;
4900        let mut lengths: Vec<f64> = Vec::new();
4901        let properties: Vec<u64> = self
4902            .properties_of_definition
4903            .as_ref()
4904            .and_then(|m| m.get(&id).cloned())
4905            .unwrap_or_default();
4906        for property in properties {
4907            let sdrs: Vec<u64> = self
4908                .sdrs_of_property
4909                .as_ref()
4910                .and_then(|m| m.get(&property).cloned())
4911                .unwrap_or_default();
4912            for sdr in sdrs {
4913                let Ok(args) = self.args(sdr, "SHAPE_DEFINITION_REPRESENTATION") else {
4914                    continue;
4915                };
4916                let Some(rep) = args.get(1).and_then(Arg::reference) else {
4917                    continue;
4918                };
4919                for item in self.representation_items(rep).unwrap_or_default() {
4920                    let keyword = self
4921                        .instance(item)
4922                        .map(|i| i.keyword().to_owned())
4923                        .unwrap_or_default();
4924                    match keyword.as_str() {
4925                        "AXIS2_PLACEMENT_3D" => frame = self.frame(item).ok(),
4926                        "LENGTH_MEASURE_WITH_UNIT" | "MEASURE_REPRESENTATION_ITEM" => {
4927                            if let Some((value, _)) = self.measure_value(item) {
4928                                lengths.push(value);
4929                            }
4930                        }
4931                        _ => {}
4932                    }
4933                }
4934            }
4935        }
4936
4937        // What the target *is* follows from the description the file gives
4938        // and how many sizes it carries: an area needs two, a circle one, a
4939        // line one, a point none.
4940        let kind = if description.contains("circle") || description.contains("circular") {
4941            ogeom_doc::DatumTargetKind::Circle {
4942                diameter: lengths.first().copied().unwrap_or(0.0),
4943            }
4944        } else if description.contains("rectangle") || lengths.len() >= 2 {
4945            ogeom_doc::DatumTargetKind::Rectangle {
4946                length: lengths.first().copied().unwrap_or(0.0),
4947                width: lengths.get(1).copied().unwrap_or(0.0),
4948            }
4949        } else if description.contains("line") || lengths.len() == 1 {
4950            ogeom_doc::DatumTargetKind::Line {
4951                length: lengths.first().copied().unwrap_or(0.0),
4952            }
4953        } else {
4954            ogeom_doc::DatumTargetKind::Point
4955        };
4956        Some(ogeom_doc::DatumTarget {
4957            datum,
4958            index,
4959            kind,
4960            at: frame.map_or(Point::ORIGIN, |f: Frame| f.origin()),
4961            frame,
4962            items: items_for(id),
4963        })
4964    }
4965
4966    /// The drawn annotations: callouts, their polylines, their planes, and
4967    /// which semantic annotation each one draws.
4968    fn callouts(
4969        &mut self,
4970        annotation_ids: &FastMap<u64, ogeom_doc::Annotated>,
4971        aspect_items: &FastMap<u64, Vec<ogeom_topo::TShapeId>>,
4972    ) -> (Vec<ogeom_doc::Callout>, FastMap<u64, usize>) {
4973        // Which callout each annotation plane holds, and the plane's frame.
4974        let mut plane_of: FastMap<u64, Frame> = FastMap::default();
4975        for id in self.ids_with("ANNOTATION_PLANE") {
4976            let Ok(args) = self.args(id, "ANNOTATION_PLANE") else {
4977                continue;
4978            };
4979            let frame = args
4980                .get(2)
4981                .and_then(Arg::reference)
4982                .and_then(|plane| {
4983                    let inner = self.args(plane, "PLANE").ok()?;
4984                    inner.get(1).and_then(Arg::reference)
4985                })
4986                .and_then(|placement| self.frame(placement).ok());
4987            let Some(frame) = frame else { continue };
4988            for element in args.get(3).and_then(Arg::list).unwrap_or(&[]) {
4989                if let Some(callout) = element.reference() {
4990                    plane_of.insert(callout, frame);
4991                }
4992            }
4993        }
4994
4995        // Which semantic annotation each callout draws. The association names
4996        // the annotation's own STEP id, so the link is made by matching that
4997        // against the ids the semantic pass already resolved.
4998        // A callout may be associated more than once (once with the shape
4999        // aspect the annotation is *about*, once with the annotation itself),
5000        // so every association is kept and the first that resolves to a
5001        // semantic annotation is the answer.
5002        let mut draws: FastMap<u64, Vec<u64>> = FastMap::default();
5003        let mut associations = self.ids_with("DRAUGHTING_MODEL_ITEM_ASSOCIATION");
5004        associations.sort_unstable();
5005        for id in associations {
5006            let Ok(args) = self.args(id, "DRAUGHTING_MODEL_ITEM_ASSOCIATION") else {
5007                continue;
5008            };
5009            if let (Some(definition), Some(item)) = (
5010                args.get(2).and_then(Arg::reference),
5011                args.get(4).and_then(Arg::reference),
5012            ) {
5013                draws.entry(item).or_default().push(definition);
5014            }
5015        }
5016
5017        let mut out = Vec::new();
5018        let mut index_of: FastMap<u64, usize> = FastMap::default();
5019        let mut callouts = self.ids_with("DRAUGHTING_CALLOUT");
5020        callouts.sort_unstable();
5021        for id in callouts {
5022            let Ok(args) = self.args(id, "DRAUGHTING_CALLOUT") else {
5023                continue;
5024            };
5025            let name = match args.first() {
5026                Some(Arg::Str(s)) => s.clone(),
5027                _ => String::new(),
5028            };
5029            let mut polylines = Vec::new();
5030            let mut text: Option<String> = None;
5031            for element in args.get(1).and_then(Arg::list).unwrap_or(&[]).to_vec() {
5032                let Some(occurrence) = element.reference() else {
5033                    continue;
5034                };
5035                if let Some(words) = self.annotation_text(occurrence) {
5036                    text.get_or_insert(words);
5037                    continue;
5038                }
5039                polylines.extend(self.annotation_polylines(occurrence));
5040            }
5041            if polylines.is_empty() && text.is_none() && !plane_of.contains_key(&id) {
5042                continue;
5043            }
5044            index_of.insert(id, out.len());
5045            let definitions = draws.get(&id).cloned().unwrap_or_default();
5046            let annotates = definitions
5047                .iter()
5048                .find_map(|d| annotation_ids.get(d).copied());
5049            // A note draws no semantic annotation; what it is about is the
5050            // shape aspect it is associated with.
5051            let mut items: Vec<ogeom_topo::TShapeId> = Vec::new();
5052            if annotates.is_none() {
5053                for d in &definitions {
5054                    for &node in aspect_items.get(d).into_iter().flatten() {
5055                        if !items.contains(&node) {
5056                            items.push(node);
5057                        }
5058                    }
5059                }
5060            }
5061            out.push(ogeom_doc::Callout {
5062                name,
5063                plane: plane_of.get(&id).copied(),
5064                polylines,
5065                annotates,
5066                items,
5067                text,
5068            });
5069        }
5070        (out, index_of)
5071    }
5072
5073    /// The words an annotation text occurrence carries: its text literal's.
5074    fn annotation_text(&mut self, occurrence: u64) -> Option<String> {
5075        let literal = {
5076            let instance = self.instance(occurrence).ok()?;
5077            let args = instance.part("ANNOTATION_TEXT_OCCURRENCE")?;
5078            args.get(2).and_then(Arg::reference)?
5079        };
5080        let args = self.args(literal, "TEXT_LITERAL").ok()?;
5081        match args.get(1) {
5082            Some(Arg::Str(words)) => Some(words.clone()),
5083            _ => None,
5084        }
5085    }
5086
5087    /// The polylines one annotation occurrence draws.
5088    ///
5089    /// A tessellated occurrence names a curve set, which names a coordinates
5090    /// list and gives each polyline as *one-based* indices into it. That is
5091    /// the whole of the drawn geometry, and it is read as it is written
5092    /// rather than resampled.
5093    fn annotation_polylines(&mut self, occurrence: u64) -> Vec<Vec<Point>> {
5094        let item = {
5095            let Ok(instance) = self.instance(occurrence) else {
5096                return Vec::new();
5097            };
5098            let args = instance
5099                .part("TESSELLATED_ANNOTATION_OCCURRENCE")
5100                .or_else(|| instance.part("ANNOTATION_OCCURRENCE"))
5101                .or_else(|| instance.part("STYLED_ITEM"));
5102            match args.and_then(|a| a.get(2).and_then(Arg::reference)) {
5103                Some(item) => item,
5104                None => return Vec::new(),
5105            }
5106        };
5107        self.tessellated_polylines(item, 0)
5108    }
5109
5110    /// The polylines a tessellated item holds, however deep it nests them.
5111    ///
5112    /// The drawn geometry of one annotation is a *set*: a frame's box, its
5113    /// leader, its text strokes, each its own curve set over its own
5114    /// coordinates list, gathered under one item, which may itself be
5115    /// repositioned by a placement, and that placement is applied here rather
5116    /// than left for a consumer to discover.
5117    fn tessellated_polylines(&mut self, item: u64, depth: usize) -> Vec<Vec<Point>> {
5118        if depth > 4 {
5119            return Vec::new();
5120        }
5121        let (children, curve_set, placement) = {
5122            let Ok(instance) = self.instance(item) else {
5123                return Vec::new();
5124            };
5125            let children: Vec<u64> = instance
5126                .part("TESSELLATED_GEOMETRIC_SET")
5127                .and_then(|args| args.first().and_then(Arg::list))
5128                .map(|list| list.iter().filter_map(Arg::reference).collect())
5129                .unwrap_or_default();
5130            let curve_set = instance.part("TESSELLATED_CURVE_SET");
5131            let placement = instance
5132                .part("REPOSITIONED_TESSELLATED_ITEM")
5133                .and_then(|args| args.first().and_then(Arg::reference));
5134            (children, curve_set, placement)
5135        };
5136
5137        let mut out = Vec::new();
5138        for child in children {
5139            out.extend(self.tessellated_polylines(child, depth + 1));
5140        }
5141        if let Some(args) = curve_set
5142            && let Some(list) = args.get(1).and_then(Arg::reference)
5143        {
5144            let points = self.coordinates_list(list);
5145            for line in args.get(2).and_then(Arg::list).unwrap_or(&[]) {
5146                let Some(indices) = line.list() else { continue };
5147                let mut polyline = Vec::with_capacity(indices.len());
5148                for index in indices {
5149                    // One-based, as the format states them.
5150                    let Some(k) = index.number() else { continue };
5151                    #[expect(
5152                        clippy::cast_possible_truncation,
5153                        clippy::cast_sign_loss,
5154                        reason = "an index into a list the file itself sized"
5155                    )]
5156                    let k = k as usize;
5157                    if k >= 1 && k <= points.len() {
5158                        polyline.push(points[k - 1]);
5159                    }
5160                }
5161                if polyline.len() >= 2 {
5162                    out.push(polyline);
5163                }
5164            }
5165        }
5166        if let Some(placement) = placement
5167            && let Ok(frame) = self.frame(placement)
5168        {
5169            for polyline in &mut out {
5170                for p in polyline.iter_mut() {
5171                    *p = frame.to_world(*p);
5172                }
5173            }
5174        }
5175        out
5176    }
5177
5178    /// A `COORDINATES_LIST`'s points, in the document's own length unit.
5179    fn coordinates_list(&mut self, id: u64) -> Vec<Point> {
5180        let Ok(args) = self.args(id, "COORDINATES_LIST") else {
5181            return Vec::new();
5182        };
5183        let scale = self.report.scale_mm;
5184        args.get(2)
5185            .and_then(Arg::list)
5186            .unwrap_or(&[])
5187            .iter()
5188            .filter_map(|entry| {
5189                let coords = entry.list()?;
5190                let value = |i: usize| coords.get(i).and_then(Arg::number).unwrap_or(0.0) * scale;
5191                Some(Point::new(value(0), value(1), value(2)))
5192            })
5193            .collect()
5194    }
5195
5196    /// Every instance id carrying a part with this keyword.
5197    fn ids_with(&self, keyword: &str) -> Vec<u64> {
5198        self.instances
5199            .iter()
5200            .filter(|(_, inst)| inst.part(keyword).is_some())
5201            .map(|(id, _)| id)
5202            .collect()
5203    }
5204
5205    /// One pass over the exchange builds both property-chain indexes:
5206    /// definition → its `PROPERTY_DEFINITION`s (by the definition argument),
5207    /// property → its `SHAPE_DEFINITION_REPRESENTATION`s (by the definition
5208    /// they represent). Ascending ids inside each list, so a lookup visits
5209    /// candidates in file order, lowest id first.
5210    fn ensure_property_indexes(&mut self) {
5211        if self.properties_of_definition.is_some() {
5212            return;
5213        }
5214        let mut properties: FastMap<u64, Vec<u64>> = FastMap::default();
5215        let mut sdrs: FastMap<u64, Vec<u64>> = FastMap::default();
5216        let instances = self.instances;
5217        for (id, instance) in instances.iter() {
5218            if let Some(args) = instance.part("PROPERTY_DEFINITION") {
5219                // Read for the index is read: the skipped table should not
5220                // claim the reader never looked.
5221                self.visited.mark(id);
5222                if let Some(definition) = args.get(2).and_then(Arg::reference) {
5223                    properties.entry(definition).or_default().push(id);
5224                }
5225            }
5226            if let Some(args) = instance.part("SHAPE_DEFINITION_REPRESENTATION") {
5227                self.visited.mark(id);
5228                if let Some(property) = args.first().and_then(Arg::reference) {
5229                    sdrs.entry(property).or_default().push(id);
5230                }
5231            }
5232        }
5233        for list in properties.values_mut().chain(sdrs.values_mut()) {
5234            list.sort_unstable();
5235        }
5236        self.properties_of_definition = Some(properties);
5237        self.sdrs_of_property = Some(sdrs);
5238    }
5239
5240    /// A measure item's value and kind, scaled into the document's units.
5241    fn measure_value(&mut self, id: u64) -> Option<(f64, ogeom_doc::MeasureKind)> {
5242        let args = {
5243            let instance = self.instance(id).ok()?;
5244            instance.part("MEASURE_WITH_UNIT")?.to_vec()
5245        };
5246        match args.first() {
5247            Some(Arg::Typed(typed)) => {
5248                let (kind, inner) = (&typed.0, &typed.1);
5249                let value = inner.first().and_then(Arg::number)?;
5250                if kind.contains("ANGLE") {
5251                    Some((value * self.angle_scale, ogeom_doc::MeasureKind::Angle))
5252                } else {
5253                    Some((value * self.report.scale_mm, ogeom_doc::MeasureKind::Length))
5254                }
5255            }
5256            _ => None,
5257        }
5258    }
5259
5260    /// A dimensional characteristic's name, kind and aspects.
5261    ///
5262    /// Sizes apply to one feature; locations (linear or angular) run
5263    /// between two. `ANGULAR_SIZE` and `ANGULAR_LOCATION` are the same
5264    /// shapes with an extra angle-selection argument at the end.
5265    fn dimension_shape(&mut self, dim: u64) -> (String, bool, Vec<u64>) {
5266        let size = {
5267            let Ok(instance) = self.instance(dim) else {
5268                return (String::new(), false, Vec::new());
5269            };
5270            instance
5271                .part("DIMENSIONAL_SIZE")
5272                .or_else(|| instance.part("ANGULAR_SIZE"))
5273        };
5274        if let Some(args) = size {
5275            let name = match args.get(1) {
5276                Some(Arg::Str(s)) => s.clone(),
5277                _ => String::new(),
5278            };
5279            let aspects = args.first().and_then(Arg::reference).into_iter().collect();
5280            return (name, false, aspects);
5281        }
5282        let location = {
5283            let Ok(instance) = self.instance(dim) else {
5284                return (String::new(), false, Vec::new());
5285            };
5286            instance
5287                .part("DIMENSIONAL_LOCATION")
5288                .or_else(|| instance.part("ANGULAR_LOCATION"))
5289        };
5290        if let Some(args) = location {
5291            let name = match args.first() {
5292                Some(Arg::Str(s)) => s.clone(),
5293                _ => String::new(),
5294            };
5295            let aspects = [args.get(2), args.get(3)]
5296                .into_iter()
5297                .flatten()
5298                .filter_map(Arg::reference)
5299                .collect();
5300            return (name, true, aspects);
5301        }
5302        (String::new(), false, Vec::new())
5303    }
5304
5305    /// The references of a datum system, primary first: each compartment's
5306    /// datum (a common datum's letters hyphen-joined) and its modifiers.
5307    /// `None` when `id` is no datum system, or names a datum no letter
5308    /// resolves, the caller then reading it as a plain reference.
5309    fn datum_system(&mut self, id: u64) -> Option<Vec<ogeom_doc::DatumReference>> {
5310        let compartments: Vec<u64> = {
5311            let instance = self.instance(id).ok()?;
5312            instance
5313                .part("DATUM_SYSTEM")?
5314                .get(4)
5315                .and_then(Arg::list)?
5316                .iter()
5317                .filter_map(Arg::reference)
5318                .collect()
5319        };
5320        let mut out = Vec::with_capacity(compartments.len());
5321        for compartment in compartments {
5322            let args = self.args(compartment, "DATUM_REFERENCE_COMPARTMENT").ok()?;
5323            let label = match args.get(4)? {
5324                Arg::Ref(base) => self.datum_letter(*base, 0)?,
5325                Arg::List(elements) => {
5326                    // A common datum: one element per constituent, each
5327                    // naming its datum as a compartment does.
5328                    let mut letters = Vec::with_capacity(elements.len());
5329                    for element in elements {
5330                        let element = element.reference()?;
5331                        let datum = match self.args(element, "DATUM_REFERENCE_ELEMENT") {
5332                            Ok(args) => args.get(4).and_then(Arg::reference)?,
5333                            Err(_) => element,
5334                        };
5335                        letters.push(self.datum_letter(datum, 0)?);
5336                    }
5337                    letters.join("-")
5338                }
5339                _ => return None,
5340            };
5341            let mut modifiers = Vec::new();
5342            for modifier in args.get(5).and_then(Arg::list).unwrap_or(&[]) {
5343                match modifier {
5344                    Arg::Typed(typed) => {
5345                        if let Some(Arg::Enum(word)) = typed.1.first() {
5346                            modifiers.push(ogeom_doc::DatumModifier::Simple(word.to_lowercase()));
5347                        }
5348                    }
5349                    Arg::Enum(word) => {
5350                        modifiers.push(ogeom_doc::DatumModifier::Simple(word.to_lowercase()));
5351                    }
5352                    Arg::Ref(valued) => {
5353                        if let Some(modifier) = self.datum_modifier_with_value(*valued) {
5354                            modifiers.push(modifier);
5355                        }
5356                    }
5357                    _ => {}
5358                }
5359            }
5360            out.push(ogeom_doc::DatumReference { label, modifiers });
5361        }
5362        Some(out)
5363    }
5364
5365    /// A `DATUM_REFERENCE_MODIFIER_WITH_VALUE`: its kind and its length.
5366    fn datum_modifier_with_value(&mut self, id: u64) -> Option<ogeom_doc::DatumModifier> {
5367        let args = self.args(id, "DATUM_REFERENCE_MODIFIER_WITH_VALUE").ok()?;
5368        let kind = match args.first()? {
5369            Arg::Enum(word) => ogeom_doc::DatumModifierKind::from_word(word)?,
5370            _ => return None,
5371        };
5372        let value = self.length_value(args.get(1)?.reference()?)?;
5373        Some(ogeom_doc::DatumModifier::WithValue { kind, value })
5374    }
5375
5376    /// A length measure with unit, in millimetres, whether written as the
5377    /// complex measure item or as the plain entity.
5378    fn length_value(&mut self, id: u64) -> Option<f64> {
5379        if let Some((value, ogeom_doc::MeasureKind::Length)) = self.measure_value(id) {
5380            return Some(value);
5381        }
5382        let args = self.args(id, "LENGTH_MEASURE_WITH_UNIT").ok()?;
5383        match args.first()? {
5384            Arg::Typed(typed) => {
5385                Some(typed.1.first().and_then(Arg::number)? * self.report.scale_mm)
5386            }
5387            _ => None,
5388        }
5389    }
5390
5391    /// The datum letter reachable from a datum reference, depth-bounded: the
5392    /// reference chain runs through compartments and systems, and files
5393    /// arrange it differently.
5394    fn datum_letter(&mut self, id: u64, depth: usize) -> Option<String> {
5395        if depth > 4 {
5396            return None;
5397        }
5398        let (letter, members, refs) = {
5399            let instance = self.instance(id).ok()?;
5400            let letter = instance.part("DATUM").and_then(|args| match args.get(4) {
5401                Some(Arg::Str(s)) if !s.is_empty() => Some(s.clone()),
5402                _ => None,
5403            });
5404            // A compartment or common datum carrying a list of constituent
5405            // references is a composite: every constituent contributes a
5406            // letter, and the letters act as one datum.
5407            let members: Vec<u64> = ["DATUM_REFERENCE_COMPARTMENT", "COMMON_DATUM"]
5408                .iter()
5409                .find_map(|k| instance.part(k))
5410                .into_iter()
5411                .flatten()
5412                .find_map(Arg::list)
5413                .map(|list| list.iter().filter_map(Arg::reference).collect())
5414                .unwrap_or_default();
5415            let mut refs = Vec::new();
5416            for (_, args) in instance.parts() {
5417                collect_refs(args, &mut refs);
5418            }
5419            (letter, members, refs)
5420        };
5421        if let Some(letter) = letter {
5422            return Some(letter);
5423        }
5424        if !members.is_empty() {
5425            let letters: Vec<String> = members
5426                .into_iter()
5427                .filter_map(|r| self.datum_letter(r, depth + 1))
5428                .collect();
5429            if !letters.is_empty() {
5430                return Some(letters.join("-"));
5431            }
5432        }
5433        refs.into_iter()
5434            .find_map(|r| self.datum_letter(r, depth + 1))
5435    }
5436}
5437
5438/// A product definition gathered from the file: its shapes and its usage
5439/// edges, before anything is committed to the document.
5440struct PdEntry {
5441    pd: u64,
5442    name: String,
5443    shapes: Vec<Shape>,
5444    /// The construction geometry its representations carry.
5445    construction: Vec<(String, ConstructionGeometry)>,
5446    children: Vec<(u64, Transform, Option<String>)>,
5447}
5448
5449/// Each wire walked the other way round: its edges in reverse order, each
5450/// reversed.
5451fn walked_back(model: &mut Model, wires: &[Shape], tol: Tolerances) -> OgeomResult<Vec<Shape>> {
5452    wires
5453        .iter()
5454        .map(|wire| {
5455            let back = model.ordered_children_of(&wire.reversed())?;
5456            Ok(make_wire(model, &back, tol)?.shape)
5457        })
5458        .collect()
5459}
5460
5461/// An edge's pcurve on a surface: the file's own where it lies within the
5462/// edge's tolerance of the curve once lifted, else derived, exact where
5463/// the pair has a closed form and fitted where it does not, with how far it
5464/// stands from the curve once lifted through the surface.
5465fn derive_pcurve(
5466    curve: &Curve,
5467    range: (f64, f64),
5468    surface: &SurfaceGeometry,
5469    stated: &[PlanarCurve],
5470    tol: Tolerances,
5471) -> PreparedPcurve {
5472    // A pcurve that does not evaluate over the edge's range states no gap
5473    // here; the face that uses it fails on it there. With nothing stated,
5474    // the gap is only asked whether it passes the confusion distance, and
5475    // by how much where it does; with the file's own to weigh it against,
5476    // it is the bound they are held to.
5477    let lifted = |pcurve: &PlanarCurve| {
5478        let gap = if stated.is_empty() {
5479            ogeom_algo::pcurve_fit::lifted_gap_past_confusion
5480        } else {
5481            ogeom_algo::pcurve_fit::lifted_gap
5482        };
5483        gap((curve, range), (pcurve, range), surface, false, tol).unwrap_or(0.0)
5484    };
5485    let nearest = nearest_stated(curve, range, surface, stated, tol);
5486    // One that follows the curve exactly needs nothing derived beside it.
5487    if let Some((curve, off)) = &nearest
5488        && *off <= tol.confusion()
5489    {
5490        return PreparedPcurve::Stated {
5491            curve: curve.clone(),
5492            off: *off,
5493        };
5494    }
5495    let derived =
5496        if let Some(exact) = ogeom_intersect::exact_pcurve_over(curve, range, surface, tol) {
5497            let exact = crate::pcurves::over_range(&exact, range);
5498            let off = lifted(&exact);
5499            PreparedPcurve::Exact { curve: exact, off }
5500        } else {
5501            // No closed form: a spline surface, or a combination the
5502            // projection table lacks. The pcurve is fitted at the curve's own
5503            // parameters, so same-parameter is preserved by construction.
5504            match crate::pcurves::fit_projected_pcurve(curve, range, surface, tol) {
5505                Ok((fitted, error, met, worst_off, warning)) => {
5506                    let off = lifted(&fitted);
5507                    PreparedPcurve::Fitted {
5508                        curve: fitted,
5509                        error,
5510                        met,
5511                        worst_off,
5512                        off,
5513                        warning,
5514                    }
5515                }
5516                Err(e) => PreparedPcurve::Refused(e.to_string()),
5517            }
5518        };
5519    // The edge's tolerance on this face is how far its curve stands from
5520    // the surface, which the projection measures. A pcurve whose trace
5521    // keeps within that of the projected trace lifts to within twice it of
5522    // the curve, and within that bound the file's pcurve is the same
5523    // boundary drawn as its sender drew it, and is kept.
5524    let allowed = match &derived {
5525        PreparedPcurve::Exact { off, .. } => Some(*off),
5526        PreparedPcurve::Fitted { off, worst_off, .. } => Some(off.max(*worst_off)),
5527        PreparedPcurve::Stated { .. } | PreparedPcurve::Refused(_) => None,
5528    };
5529    let Some(allowed) = allowed else {
5530        return derived;
5531    };
5532    let bound = 2.0f64.mul_add(allowed, tol.confusion());
5533    if let Some((curve, off)) = nearest
5534        && off <= bound
5535    {
5536        return PreparedPcurve::Stated { curve, off };
5537    }
5538    // The file may state the boundary at another pace or phase than its
5539    // curve's: a conic its frame runs the other way round, a line or a
5540    // spline over a parameter of its own. Each read at the map of the
5541    // curve's parameter that puts it on the edge's ends is the same
5542    // boundary, and is held to the same bound.
5543    let ends = match &derived {
5544        PreparedPcurve::Exact { curve: image, .. }
5545        | PreparedPcurve::Fitted { curve: image, .. } => image
5546            .point_at(range.0, tol)
5547            .ok()
5548            .zip(image.point_at(range.1, tol).ok()),
5549        PreparedPcurve::Stated { .. } | PreparedPcurve::Refused(_) => None,
5550    };
5551    if let Some(ends) = ends {
5552        let repaced: Vec<PlanarCurve> = stated
5553            .iter()
5554            .flat_map(|pcurve| repaced(pcurve, range, ends, tol))
5555            .collect();
5556        if let Some((curve, off)) = nearest_stated(curve, range, surface, &repaced, tol)
5557            && off <= bound
5558        {
5559            return PreparedPcurve::Stated { curve, off };
5560        }
5561    }
5562    derived
5563}
5564
5565/// The file's pcurve nearest the curve once lifted, with how far it
5566/// stands; within the confusion distance, only known to be within it. One
5567/// that does not evaluate over the edge's range, or whose
5568/// image leaves the surface's chart along a direction that does not close,
5569/// is left out.
5570///
5571/// A closed curve's range here may begin whole periods from where the
5572/// file's parameter began it, so each is also tried a period either way:
5573/// on a sphere a meridian's image a period off lifts to the same points,
5574/// at latitudes past the poles, and only the right one stays in the chart.
5575/// The first that follows the curve exactly ends the search.
5576fn nearest_stated(
5577    curve: &Curve,
5578    range: (f64, f64),
5579    surface: &SurfaceGeometry,
5580    stated: &[PlanarCurve],
5581    tol: Tolerances,
5582) -> Option<(PlanarCurve, f64)> {
5583    let period = curve.is_periodic().then(|| {
5584        let (lo, hi) = curve.domain();
5585        hi - lo
5586    });
5587    let turns: &[f64] = if period.is_some() {
5588        &[0.0, 1.0, -1.0]
5589    } else {
5590        &[0.0]
5591    };
5592    let mut best: Option<(PlanarCurve, f64)> = None;
5593    for pcurve in stated {
5594        for turn in turns {
5595            let Some(shifted) = period_shifted(pcurve, turn * period.unwrap_or(0.0)) else {
5596                continue;
5597            };
5598            let shifted = crate::pcurves::over_range(&shifted, range);
5599            if !within_chart(&shifted, range, surface, tol) {
5600                continue;
5601            }
5602            let Ok(off) = ogeom_algo::pcurve_fit::lifted_gap_past_confusion(
5603                (curve, range),
5604                (&shifted, range),
5605                surface,
5606                false,
5607                tol,
5608            ) else {
5609                continue;
5610            };
5611            if best.as_ref().is_none_or(|(_, held)| off < *held) {
5612                best = Some((shifted, off));
5613            }
5614            if off <= tol.confusion() {
5615                return best;
5616            }
5617        }
5618    }
5619    best
5620}
5621
5622/// A pcurve read `delta` later along its parameter: at `t`, the point the
5623/// pcurve has at `t - delta`. `None` for a kind that cannot be restated so.
5624fn period_shifted(pcurve: &PlanarCurve, delta: f64) -> Option<PlanarCurve> {
5625    if delta == 0.0 {
5626        return Some(pcurve.clone());
5627    }
5628    match pcurve {
5629        PlanarCurve::Line(l) => {
5630            let axis = l.axis();
5631            let moved = ogeom_math::Axis2 {
5632                location: axis.location - axis.direction.vector() * delta,
5633                direction: axis.direction,
5634            };
5635            let (lo, hi) = l.domain();
5636            ogeom_geom::Line2d::over(moved, lo + delta, hi + delta)
5637                .ok()
5638                .map(Into::into)
5639        }
5640        PlanarCurve::BSpline(b) => {
5641            let knots: Vec<f64> = b.knots().knots().iter().map(|k| k + delta).collect();
5642            let knots = KnotVector::new(knots, b.knots().degree()).ok()?;
5643            ogeom_geom::BSpline2d::rational(knots, b.control_points().to_vec())
5644                .ok()
5645                .map(Into::into)
5646        }
5647        // A conic's parameter is its angle: whole turns leave it in place.
5648        PlanarCurve::Circle(_) | PlanarCurve::Ellipse(_)
5649            if (delta / core::f64::consts::TAU).fract().abs() < 1e-12 =>
5650        {
5651            Some(pcurve.clone())
5652        }
5653        _ => None,
5654    }
5655}
5656
5657/// A file's pcurve in this kernel's parameters: its chart's coordinates
5658/// stretched from the file's units, `u` by `su` and `v` by `sv`, and its
5659/// parameter carried onto the edge's curve's here, which runs `pace` per
5660/// file unit. A conic as read already runs in radians, `angle` per file
5661/// unit; a line and a spline run in the file's own. `None` where no curve
5662/// here states the image at the curve's parameters: a conic paced unlike
5663/// its curve or stretched unevenly.
5664fn in_kernel_units(
5665    pcurve: &PlanarCurve,
5666    (su, sv): (f64, f64),
5667    pace: f64,
5668    angle: f64,
5669    tol: Tolerances,
5670) -> Option<PlanarCurve> {
5671    let own = match pcurve {
5672        PlanarCurve::Circle(_) | PlanarCurve::Ellipse(_) => angle,
5673        _ => 1.0,
5674    };
5675    if su == 1.0 && sv == 1.0 && own == pace {
5676        return Some(pcurve.clone());
5677    }
5678    match pcurve {
5679        PlanarCurve::Line(l) => {
5680            // Stretched, the line runs `speed` along its new direction per
5681            // unit of its parameter; at the curve's `t` it stands at
5682            // `t·own/pace` along that parameter. The speed is the stretch
5683            // itself along an axis, so a line in the file's own pace comes
5684            // out at exactly this kernel's.
5685            let axis = l.axis();
5686            let d = axis.direction.vector();
5687            let along = ogeom_math::Vector2::new(d.x * su, d.y * sv);
5688            let speed = if su == sv || d.y == 0.0 {
5689                su.abs()
5690            } else if d.x == 0.0 {
5691                sv.abs()
5692            } else {
5693                along.magnitude()
5694            };
5695            let origin = ogeom_math::Point2::new(axis.location.x * su, axis.location.y * sv);
5696            let line =
5697                ogeom_math::Axis2::new(origin, ogeom_math::Direction2::new(along, tol).ok()?);
5698            let (lo, hi) = l.domain();
5699            let stretched: PlanarCurve = ogeom_geom::Line2d::over(line, lo * speed, hi * speed)
5700                .ok()?
5701                .into();
5702            paced(&stretched, speed * own / pace, 0.0, tol)
5703        }
5704        PlanarCurve::Circle(_) | PlanarCurve::Ellipse(_) => {
5705            if own != pace {
5706                return None;
5707            }
5708            pcurve.stretched(su, sv, tol).ok()
5709        }
5710        PlanarCurve::BSpline(_) => {
5711            let stretched = pcurve.stretched(su, sv, tol).ok()?;
5712            paced(&stretched, own / pace, 0.0, tol)
5713        }
5714        _ => None,
5715    }
5716}
5717
5718/// An affine map of a chart's coordinates: `linear` times the point, plus
5719/// `offset`.
5720#[derive(Debug, Clone, Copy)]
5721struct ChartMap {
5722    linear: [[f64; 2]; 2],
5723    offset: (f64, f64),
5724}
5725
5726impl ChartMap {
5727    fn point(&self, p: ogeom_math::Point2) -> ogeom_math::Point2 {
5728        let [[a, b], [c, d]] = self.linear;
5729        ogeom_math::Point2::new(
5730            a.mul_add(p.x, b * p.y) + self.offset.0,
5731            c.mul_add(p.x, d * p.y) + self.offset.1,
5732        )
5733    }
5734
5735    fn vector(&self, v: ogeom_math::Vector2) -> ogeom_math::Vector2 {
5736        let [[a, b], [c, d]] = self.linear;
5737        ogeom_math::Vector2::new(a.mul_add(v.x, b * v.y), c.mul_add(v.x, d * v.y))
5738    }
5739}
5740
5741/// A file's pcurve carried through `map` into this kernel's chart, its
5742/// parameter carried onto the edge's curve's here, which runs `pace` per
5743/// file unit. An affine map takes a line to a line and a spline to the
5744/// spline on its mapped control points, both exactly, at their own
5745/// parameters; `None` for a conic, whose image under a map that is not a
5746/// similarity is an ellipse at another phase than any stated here, and
5747/// for anything else.
5748fn through_map(
5749    pcurve: &PlanarCurve,
5750    map: ChartMap,
5751    pace: f64,
5752    tol: Tolerances,
5753) -> Option<PlanarCurve> {
5754    match pcurve {
5755        PlanarCurve::Line(l) => {
5756            let axis = l.axis();
5757            let along = map.vector(axis.direction.vector());
5758            let speed = along.magnitude();
5759            let line = ogeom_math::Axis2::new(
5760                map.point(axis.location),
5761                ogeom_math::Direction2::new(along, tol).ok()?,
5762            );
5763            let (lo, hi) = l.domain();
5764            let mapped: PlanarCurve = ogeom_geom::Line2d::over(line, lo * speed, hi * speed)
5765                .ok()?
5766                .into();
5767            paced(&mapped, speed / pace, 0.0, tol)
5768        }
5769        PlanarCurve::BSpline(b) => {
5770            let control = b
5771                .control_points()
5772                .iter()
5773                .map(|w| ogeom_math::Weighted::new(map.point(w.point()), w.weight, tol))
5774                .collect::<OgeomResult<Vec<_>>>()
5775                .ok()?;
5776            let mapped: PlanarCurve = ogeom_geom::BSpline2d::rational(b.knots().clone(), control)
5777                .ok()?
5778                .into();
5779            paced(&mapped, 1.0 / pace, 0.0, tol)
5780        }
5781        _ => None,
5782    }
5783}
5784
5785/// A pcurve read at an affine map of the curve's parameter: at `t`, the
5786/// point `pcurve` has at `a·t + b`. Exact for each kind it answers: a line
5787/// (straight at any pace, a line of its own at a pace of one either way), a
5788/// spline (its knots carried back through the map), a circle at a pace of
5789/// one either way (its frame turned by the phase), and an ellipse the same
5790/// at a phase of whole half turns, the only ones its frame turns through
5791/// with its axes as they are. `None` for anything else.
5792fn paced(pcurve: &PlanarCurve, a: f64, b: f64, tol: Tolerances) -> Option<PlanarCurve> {
5793    use ogeom_geom::Reversible as _;
5794    if !a.is_finite() || !b.is_finite() || a == 0.0 {
5795        return None;
5796    }
5797    if a == 1.0 && b == 0.0 {
5798        return Some(pcurve.clone());
5799    }
5800    // Where the map sends each end of the pcurve's own domain, in order.
5801    let back = |lo: f64, hi: f64| {
5802        let (x, y) = ((lo - b) / a, (hi - b) / a);
5803        (x.min(y), x.max(y))
5804    };
5805    match pcurve {
5806        PlanarCurve::Line(l) => {
5807            let axis = l.axis();
5808            let (lo, hi) = l.domain();
5809            let (from, to) = back(lo, hi);
5810            if a.abs() == 1.0 {
5811                let direction = if a > 0.0 {
5812                    axis.direction
5813                } else {
5814                    axis.direction.reversed()
5815                };
5816                let line = ogeom_math::Axis2::new(axis.point_at(b), direction);
5817                return ogeom_geom::Line2d::over(line, from, to)
5818                    .ok()
5819                    .map(Into::into);
5820            }
5821            let at = |t: f64| axis.point_at(a.mul_add(t, b));
5822            let knots = KnotVector::new(vec![from, from, to, to], 1).ok()?;
5823            ogeom_geom::BSpline2d::new(knots, vec![at(from), at(to)], tol)
5824                .ok()
5825                .map(Into::into)
5826        }
5827        PlanarCurve::BSpline(spline) => {
5828            let mut knots: Vec<f64> = spline.knots().knots().iter().map(|k| (k - b) / a).collect();
5829            let mut control = spline.control_points().to_vec();
5830            if a < 0.0 {
5831                knots.reverse();
5832                control.reverse();
5833            }
5834            let knots = KnotVector::new(knots, spline.knots().degree()).ok()?;
5835            ogeom_geom::BSpline2d::rational(knots, control)
5836                .ok()
5837                .map(Into::into)
5838        }
5839        PlanarCurve::Circle(c) if a.abs() == 1.0 => {
5840            // At `τ` the circle stands at angle `σ·τ` in its frame, `σ`
5841            // the way it runs; at `a·t + b` that is `σ·a·t + σ·b`.
5842            let sense = if c.is_reversed() { -1.0 } else { 1.0 };
5843            let circle = c.circle();
5844            let frame = circle.frame();
5845            let (sin, cos) = (sense * b).sin_cos();
5846            let (x, y) = (frame.x().vector(), frame.y().vector());
5847            let turned = ogeom_math::Frame2::from_axes(
5848                frame.origin(),
5849                ogeom_math::Direction2::new(x * cos + y * sin, tol).ok()?,
5850                ogeom_math::Direction2::new(y * cos - x * sin, tol).ok()?,
5851                tol,
5852            )
5853            .ok()?;
5854            let out: PlanarCurve = ogeom_geom::Circle2d::new(
5855                ogeom_math::Circle2::new(turned, circle.radius(), tol).ok()?,
5856            )
5857            .into();
5858            Some(if sense * a < 0.0 { out.reversed() } else { out })
5859        }
5860        PlanarCurve::Ellipse(e) if a.abs() == 1.0 => {
5861            let half_turns = (b / core::f64::consts::PI).round();
5862            if core::f64::consts::PI * half_turns != b {
5863                return None;
5864            }
5865            let sense = if e.is_reversed() { -1.0 } else { 1.0 };
5866            let ellipse = e.ellipse();
5867            let frame = ellipse.frame();
5868            let frame = if half_turns.rem_euclid(2.0) == 0.0 {
5869                frame
5870            } else {
5871                ogeom_math::Frame2::from_axes(
5872                    frame.origin(),
5873                    frame.x().reversed(),
5874                    frame.y().reversed(),
5875                    tol,
5876                )
5877                .ok()?
5878            };
5879            let out: PlanarCurve = ogeom_geom::Ellipse2d::new(
5880                ogeom_math::Ellipse2::new(
5881                    frame,
5882                    ellipse.major_radius(),
5883                    ellipse.minor_radius(),
5884                    tol,
5885                )
5886                .ok()?,
5887            )
5888            .into();
5889            Some(if sense * a < 0.0 { out.reversed() } else { out })
5890        }
5891        _ => None,
5892    }
5893}
5894
5895/// The ways a file's pcurve may follow the edge's curve at another pace or
5896/// phase than the curve's own, each read at an affine map of the curve's
5897/// parameter that puts its ends where `ends` stand in the chart (the
5898/// edge's ends there, from a pcurve derived for it): forward and reversed
5899/// at the curve's own pace, and a line at the pace its two ends ask for, a
5900/// spline over its own domain either way.
5901fn repaced(
5902    pcurve: &PlanarCurve,
5903    range: (f64, f64),
5904    ends: (ogeom_math::Point2, ogeom_math::Point2),
5905    tol: Tolerances,
5906) -> Vec<PlanarCurve> {
5907    // A straight spline of two points (a line the file paces other than
5908    // by length) is the line through them, at whatever pace it runs.
5909    let straight = match pcurve {
5910        PlanarCurve::BSpline(spline) if spline.knots().degree() == 1 && !spline.is_rational() => {
5911            match spline.control_points() {
5912                [from, to] => Some((from.point(), to.point())),
5913                _ => None,
5914            }
5915        }
5916        _ => None,
5917    };
5918    let line;
5919    let pcurve = match straight {
5920        Some((from, to)) => {
5921            let Ok(segment) = ogeom_geom::Line2d::segment(from, to, tol) else {
5922                return Vec::new();
5923            };
5924            line = PlanarCurve::from(segment);
5925            &line
5926        }
5927        None => pcurve,
5928    };
5929    let span = range.1 - range.0;
5930    // The parameter of the pcurve's point nearest `w`, for the kinds that
5931    // have it in closed form.
5932    let foot = |w: ogeom_math::Point2| -> Option<f64> {
5933        match pcurve {
5934            PlanarCurve::Line(l) => {
5935                let axis = l.axis();
5936                Some((w - axis.location).dot(axis.direction.vector()))
5937            }
5938            PlanarCurve::Circle(c) => {
5939                let frame = c.circle().frame();
5940                let r = w - frame.origin();
5941                let angle = r.dot(frame.y().vector()).atan2(r.dot(frame.x().vector()));
5942                Some(if c.is_reversed() { -angle } else { angle })
5943            }
5944            PlanarCurve::Ellipse(e) => {
5945                let ellipse = e.ellipse();
5946                let frame = ellipse.frame();
5947                let r = w - frame.origin();
5948                let angle = (r.dot(frame.y().vector()) / ellipse.minor_radius())
5949                    .atan2(r.dot(frame.x().vector()) / ellipse.major_radius());
5950                Some(if e.is_reversed() { -angle } else { angle })
5951            }
5952            _ => None,
5953        }
5954    };
5955    let mut maps: Vec<(f64, f64)> = Vec::new();
5956    match pcurve {
5957        PlanarCurve::BSpline(spline) => {
5958            let (lo, hi) = spline.domain();
5959            let rate = (hi - lo) / span;
5960            maps.push((rate, rate.mul_add(-range.0, lo)));
5961            maps.push((-rate, rate.mul_add(range.0, hi)));
5962        }
5963        PlanarCurve::Line(_) | PlanarCurve::Circle(_) | PlanarCurve::Ellipse(_) => {
5964            let Some(start) = foot(ends.0) else {
5965                return Vec::new();
5966            };
5967            for a in [1.0_f64, -1.0] {
5968                let b = a.mul_add(-range.0, start);
5969                // An ellipse's frame turns only by whole half turns.
5970                let b = if matches!(pcurve, PlanarCurve::Ellipse(_)) {
5971                    core::f64::consts::PI * (b / core::f64::consts::PI).round()
5972                } else {
5973                    b
5974                };
5975                maps.push((a, b));
5976            }
5977            if let PlanarCurve::Line(_) = pcurve
5978                && let Some(end) = foot(ends.1)
5979            {
5980                let rate = (end - start) / span;
5981                maps.push((rate, rate.mul_add(-range.0, start)));
5982            }
5983        }
5984        _ => {}
5985    }
5986    maps.into_iter()
5987        .filter_map(|(a, b)| paced(pcurve, a, b, tol))
5988        .collect()
5989}
5990
5991/// Whether a pcurve's image over a range stays inside the surface's chart
5992/// along each direction that does not close on itself.
5993fn within_chart(
5994    pcurve: &PlanarCurve,
5995    range: (f64, f64),
5996    surface: &SurfaceGeometry,
5997    tol: Tolerances,
5998) -> bool {
5999    let ((u0, u1), (v0, v1)) = surface.domain();
6000    let slack = tol.parametric();
6001    (0..=4).all(|i| {
6002        let t = (range.1 - range.0).mul_add(f64::from(i) / 4.0, range.0);
6003        pcurve.point_at(t, tol).is_ok_and(|p| {
6004            (surface.is_periodic_u() || (p.x >= u0 - slack && p.x <= u1 + slack))
6005                && (surface.is_periodic_v() || (p.y >= v0 - slack && p.y <= v1 + slack))
6006        })
6007    })
6008}
6009
6010/// Every reference in an argument tree, in order.
6011fn collect_refs(args: &[Arg], out: &mut Vec<u64>) {
6012    for arg in args {
6013        match arg {
6014            Arg::Ref(id) => out.push(*id),
6015            Arg::List(inner) => collect_refs(inner, out),
6016            Arg::Typed(typed) => collect_refs(&typed.1, out),
6017            _ => {}
6018        }
6019    }
6020}
6021
6022/// Whether two points stand on one column about `circle`'s axis: the same
6023/// angle about it, to within the confusion distance along the circle
6024/// through `q`.
6025fn same_column(circle: &ogeom_math::Circle, p: Point, q: Point, tol: Tolerances) -> bool {
6026    let (lp, lq) = (circle.frame().to_local(p), circle.frame().to_local(q));
6027    let apart = (lp.y.atan2(lp.x) - lq.y.atan2(lq.x)).rem_euclid(core::f64::consts::TAU);
6028    let turn = apart.min(core::f64::consts::TAU - apart);
6029    turn * lq.x.hypot(lq.y) <= tol.confusion()
6030}
6031
6032/// For a two-wire periodic face: each wire's single closed edge with its
6033/// vertex, empty when the shape is anything else.
6034/// The pole of `ball` a ring walked as `ring` turns toward: the side its
6035/// material lies on, left of the walk about the face's own normal, which is
6036/// the surface's (its chart's `du x dv`, inward on a ball framed
6037/// left-handed) where the face runs with its surface. `None` where the ring
6038/// has no curve to walk or stands square to neither pole.
6039fn pole_on_the_left(
6040    model: &Model,
6041    surface: &SurfaceGeometry,
6042    ball: ogeom_math::Sphere,
6043    ring: &Shape,
6044    at: &Shape,
6045    face_forward: bool,
6046    tol: Tolerances,
6047) -> OgeomResult<Option<Point>> {
6048    let Some(data) = model.node(ring).and_then(|n| n.data().as_edge()) else {
6049        return Ok(None);
6050    };
6051    let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
6052        return Ok(None);
6053    };
6054    let Some(curve) = model.geometry().curve(*curve) else {
6055        return Ok(None);
6056    };
6057    let Some(start) = model
6058        .node(at)
6059        .and_then(|n| n.data().as_vertex())
6060        .map(|v| v.point)
6061    else {
6062        return Ok(None);
6063    };
6064    let reversed = ring.orientation() == ogeom_topo::Orientation::Reversed;
6065    let t = if reversed { range.1 } else { range.0 };
6066    let along = curve.d1_at(t, tol)? * if reversed { -1.0 } else { 1.0 };
6067    let local = ball.frame().to_local(start);
6068    let (u, v) = (
6069        local.y.atan2(local.x),
6070        (local.z / ball.radius()).clamp(-1.0, 1.0).asin(),
6071    );
6072    let (du, dv) = surface.d1_at(u, v, tol)?;
6073    let own = du.cross(dv);
6074    let normal = if face_forward { own } else { -own };
6075    let left = normal.cross(along);
6076    let axis = ball.frame().z().vector() * ball.radius();
6077    let lean = left.dot(axis);
6078
6079    if lean.abs() <= tol.confusion() * left.magnitude() {
6080        return Ok(None);
6081    }
6082    Ok(Some(if lean > 0.0 {
6083        ball.centre() + axis
6084    } else {
6085        ball.centre() - axis
6086    }))
6087}
6088
6089fn closed_ring_edges(model: &Model, wires: &[Shape]) -> OgeomResult<Vec<(Shape, Shape)>> {
6090    let mut out = Vec::new();
6091    for wire in wires {
6092        let edges = ogeom_topo::explore(
6093            model,
6094            wire,
6095            ogeom_topo::Filter::OfType(ogeom_topo::ShapeType::Edge),
6096        )?;
6097        if edges.len() != 1 {
6098            return Ok(Vec::new());
6099        }
6100        let edge = edges[0].clone();
6101        let Some((a, b)) = ogeom_algo::edge_vertices(model, &edge)? else {
6102            return Ok(Vec::new());
6103        };
6104        if !a.is_same(&b) {
6105            return Ok(Vec::new());
6106        }
6107        out.push((edge, a));
6108    }
6109    Ok(out)
6110}
6111
6112/// The window of `curve` between the feet of two vertices that stand on it
6113/// short of its ends: both within a hair of the curve, in the curve's own
6114/// order, and not the whole curve. `None` where the vertices are off the
6115/// curve, reversed against it, or already at its ends.
6116fn window_between_feet(
6117    curve: &Curve,
6118    start: Point,
6119    end: Point,
6120    tol: Tolerances,
6121) -> Option<(f64, f64)> {
6122    let (lo, hi) = curve.domain();
6123    let samples = match curve {
6124        Curve::BSpline(spline) => (spline.control_points().len() * 8).max(64),
6125        _ => 64,
6126    };
6127    let foot = |p: Point| -> Option<f64> {
6128        let found = project_on_curve(curve, p, samples, tol).ok()?;
6129        (found.distance <= tol.confusion() * 1e4).then_some(found.parameter)
6130    };
6131    let (a, b) = (foot(start)?, foot(end)?);
6132    let slack = tol.parametric().max((hi - lo) * 1e-9);
6133    if b <= a + slack {
6134        return None;
6135    }
6136    if (a - lo).abs() <= slack && (b - hi).abs() <= slack {
6137        return None;
6138    }
6139    Some((a, b))
6140}
6141
6142/// The knots a STEP B-spline form implies when it states none: a Bézier
6143/// form's pieces joined at whole numbers, a uniform form's knots one apart
6144/// from `-degree`, a quasi-uniform form's the same clamped at both ends.
6145fn implied_knots(form: &str, degree: usize, count: usize) -> Vec<f64> {
6146    let p = degree.max(1);
6147    #[allow(clippy::cast_precision_loss)]
6148    let at = |i: usize| i as f64;
6149    if form.starts_with("BEZIER") {
6150        let pieces = (count.saturating_sub(1) / p).max(1);
6151        let mut out = vec![0.0; p + 1];
6152        for s in 1..pieces {
6153            out.extend(std::iter::repeat_n(at(s), p));
6154        }
6155        out.extend(std::iter::repeat_n(at(pieces), p + 1));
6156        out
6157    } else if form.starts_with("QUASI_UNIFORM") {
6158        let interior = count.saturating_sub(p + 1);
6159        let mut out = vec![0.0; p + 1];
6160        out.extend((1..=interior).map(at));
6161        out.extend(std::iter::repeat_n(at(interior + 1), p + 1));
6162        out
6163    } else {
6164        #[allow(clippy::cast_precision_loss)]
6165        (0..count + p + 1).map(|i| i as f64 - p as f64).collect()
6166    }
6167}
6168
6169/// Two splines joined end to start: the lower degree raised to the
6170/// higher, the second's weights scaled to meet the first's at the join (a
6171/// rational curve is unchanged by scaling all its weights), and the join
6172/// knot left at full multiplicity.
6173fn join_splines(
6174    held: (KnotVector, Vec<Weighted<Point>>),
6175    next: &BSplineCurve,
6176    tol: Tolerances,
6177) -> OgeomResult<(KnotVector, Vec<Weighted<Point>>)> {
6178    let (mut knots, mut control) = held;
6179    let mut next = next.clone();
6180    while next.degree() < knots.degree() {
6181        next = next.elevated(tol)?;
6182    }
6183    while knots.degree() < next.degree() {
6184        (knots, control) = ogeom_math::bspline::elevate_degree(&knots, &control, tol)?;
6185    }
6186    let (Some(end), Some(start)) = (control.last(), next.control_points().first()) else {
6187        ogeom_bail!(Construction, "a composite segment has no control points");
6188    };
6189    let factor = end.weight / start.weight;
6190    let scaled: Vec<Weighted<Point>> = next
6191        .control_points()
6192        .iter()
6193        .map(|w| Weighted {
6194            scaled: w.scaled.scale(factor),
6195            weight: w.weight * factor,
6196        })
6197        .collect();
6198    ogeom_math::bspline::join(&(knots, control), &(next.knots().clone(), scaled))
6199}
6200
6201/// One patch of a composite surface as a tensor spline: its knots each way,
6202/// on the unit interval, and its net indexed `[u][v]`.
6203#[derive(Debug, Clone)]
6204struct Patch {
6205    u: KnotVector,
6206    v: KnotVector,
6207    net: Vec<Vec<Weighted<Point>>>,
6208}
6209
6210impl Patch {
6211    fn of(surface: &ogeom_geom::BSplineSurface) -> OgeomResult<Self> {
6212        let grid = surface.grid();
6213        let (nu, nv) = (grid.u_count(), grid.v_count());
6214        let points = grid.points();
6215        let net = (0..nu)
6216            .map(|i| (0..nv).map(|j| points[i * nv + j]).collect())
6217            .collect();
6218        Ok(Self {
6219            u: surface.u_knots().reparameterized(0.0, 1.0)?,
6220            v: surface.v_knots().reparameterized(0.0, 1.0)?,
6221            net,
6222        })
6223    }
6224
6225    fn transposed(self) -> Self {
6226        let (nu, nv) = (self.net.len(), self.net[0].len());
6227        let net = (0..nv)
6228            .map(|j| (0..nu).map(|i| self.net[i][j]).collect())
6229            .collect();
6230        Self {
6231            u: self.v,
6232            v: self.u,
6233            net,
6234        }
6235    }
6236
6237    fn reversed_u(mut self) -> Self {
6238        self.net.reverse();
6239        self.u = self.u.reversed();
6240        self
6241    }
6242
6243    /// Apply a curve operation along `u` to every column of the net.
6244    fn along_u(
6245        self,
6246        op: impl Fn(&KnotVector, &[Weighted<Point>]) -> OgeomResult<(KnotVector, Vec<Weighted<Point>>)>,
6247    ) -> OgeomResult<Self> {
6248        let nv = self.net[0].len();
6249        let mut knots = self.u.clone();
6250        let mut columns = Vec::with_capacity(nv);
6251        for j in 0..nv {
6252            let column: Vec<Weighted<Point>> = self.net.iter().map(|row| row[j]).collect();
6253            let (k, c) = op(&self.u, &column)?;
6254            knots = k;
6255            columns.push(c);
6256        }
6257        let nu = columns[0].len();
6258        let net = (0..nu)
6259            .map(|i| (0..nv).map(|j| columns[j][i]).collect())
6260            .collect();
6261        Ok(Self {
6262            u: knots,
6263            v: self.v,
6264            net,
6265        })
6266    }
6267}
6268
6269/// A grid of patches (`grid[i][j]`, `i` along `u`) joined into one surface.
6270fn join_patches(mut grid: Vec<Vec<Patch>>, tol: Tolerances) -> OgeomResult<SurfaceGeometry> {
6271    let (m, n) = (grid.len(), grid[0].len());
6272    // Degrees raised to the grid's highest each way.
6273    let pu = grid
6274        .iter()
6275        .flatten()
6276        .map(|p| p.u.degree())
6277        .max()
6278        .unwrap_or(1);
6279    let pv = grid
6280        .iter()
6281        .flatten()
6282        .map(|p| p.v.degree())
6283        .max()
6284        .unwrap_or(1);
6285    for patch in grid.iter_mut().flatten() {
6286        let mut p = patch.clone();
6287        while p.u.degree() < pu {
6288            p = p.along_u(|k, c| ogeom_math::bspline::elevate_degree(k, c, tol))?;
6289        }
6290        p = p.transposed();
6291        while p.u.degree() < pv {
6292            p = p.along_u(|k, c| ogeom_math::bspline::elevate_degree(k, c, tol))?;
6293        }
6294        *patch = p.transposed();
6295    }
6296    // Knots unified: along u within each grid row of patches sharing an
6297    // index `i` (their u knots must agree to share v boundaries), along v
6298    // within each index `j`.
6299    let unify = |patches: Vec<Patch>, along_v: bool| -> OgeomResult<Vec<Patch>> {
6300        let turned: Vec<Patch> = if along_v {
6301            patches.into_iter().map(Patch::transposed).collect()
6302        } else {
6303            patches
6304        };
6305        let mut wanted: Vec<(f64, usize)> = Vec::new();
6306        for p in &turned {
6307            for (value, count) in p.u.distinct() {
6308                if value <= 1e-12 || value >= 1.0 - 1e-12 {
6309                    continue;
6310                }
6311                match wanted.iter_mut().find(|(v, _)| (v - value).abs() <= 1e-12) {
6312                    Some(slot) => slot.1 = slot.1.max(count),
6313                    None => wanted.push((value, count)),
6314                }
6315            }
6316        }
6317        let mut out = Vec::with_capacity(turned.len());
6318        for mut p in turned {
6319            for &(value, count) in &wanted {
6320                let have = p.u.multiplicity_of(value);
6321                if have < count {
6322                    p = p.along_u(|k, c| {
6323                        ogeom_math::bspline::insert_knot(k, c, value, count - have, tol)
6324                    })?;
6325                }
6326            }
6327            out.push(if along_v { p.transposed() } else { p });
6328        }
6329        Ok(out)
6330    };
6331    for row in &mut grid {
6332        *row = unify(std::mem::take(row), true)?;
6333    }
6334    for j in 0..n {
6335        let column: Vec<Patch> = grid.iter().map(|row| row[j].clone()).collect();
6336        for (row, p) in grid.iter_mut().zip(unify(column, false)?) {
6337            row[j] = p;
6338        }
6339    }
6340    // Joined: shared boundaries once, each join a knot of full multiplicity.
6341    let near = |a: &Weighted<Point>, b: &Weighted<Point>| {
6342        a.point().distance(b.point()) <= tol.confusion() * 100.0
6343            && (a.weight - b.weight).abs() <= 1e-9 * a.weight.abs().max(1.0)
6344    };
6345    let mut rows: Vec<Vec<Weighted<Point>>> = Vec::new();
6346    for (i, grid_row) in grid.iter().enumerate() {
6347        let nu = grid_row[0].net.len();
6348        for a in 0..nu {
6349            if i > 0 && a == 0 {
6350                // The row this patch shares with the one before it.
6351                let prev = rows.len() - 1;
6352                let mut shared = 0;
6353                for (j, patch) in grid_row.iter().enumerate() {
6354                    for (b, cell) in patch.net[0].iter().enumerate() {
6355                        if j > 0 && b == 0 {
6356                            continue;
6357                        }
6358                        if !near(&rows[prev][shared], cell) {
6359                            ogeom_bail!(Construction, "neighbouring patches part along u");
6360                        }
6361                        shared += 1;
6362                    }
6363                }
6364                continue;
6365            }
6366            let mut row = Vec::new();
6367            for (j, patch) in grid_row.iter().enumerate() {
6368                for (b, cell) in patch.net[a].iter().enumerate() {
6369                    if j > 0 && b == 0 {
6370                        if !near(row.last().unwrap_or(cell), cell) {
6371                            ogeom_bail!(Construction, "neighbouring patches part along v");
6372                        }
6373                        continue;
6374                    }
6375                    row.push(*cell);
6376                }
6377            }
6378            rows.push(row);
6379        }
6380    }
6381    // Each patch's knots shifted to its own unit of the whole; at a join
6382    // one copy of the shared end goes, leaving the multiplicity at the
6383    // degree, and the next patch's run follows past its own clamped start.
6384    let joined_knots = |parts: Vec<&KnotVector>, degree: usize| -> OgeomResult<KnotVector> {
6385        let mut out: Vec<f64> = Vec::new();
6386        for (k, knots) in parts.iter().enumerate() {
6387            #[allow(clippy::cast_precision_loss)]
6388            let shift = k as f64;
6389            let values: Vec<f64> = knots.knots().iter().map(|x| x + shift).collect();
6390            if k == 0 {
6391                out.extend(values);
6392            } else {
6393                out.pop();
6394                out.extend_from_slice(&values[degree + 1..]);
6395            }
6396        }
6397        KnotVector::new(out, degree)
6398    };
6399    let u = joined_knots((0..m).map(|i| &grid[i][0].u).collect(), pu)?;
6400    let v = joined_knots((0..n).map(|j| &grid[0][j].v).collect(), pv)?;
6401    let (nu, nv) = (rows.len(), rows[0].len());
6402    let cells: Vec<Weighted<Point>> = rows.into_iter().flatten().collect();
6403    Ok(
6404        ogeom_geom::BSplineSurface::rational(u, v, ogeom_math::ControlGrid::new(cells, nu, nv)?)?
6405            .into(),
6406    )
6407}
6408
6409/// The highest spline degree a file may state. The standard sets no limit;
6410/// exporters stay far below this, and a degree past it would only size
6411/// allocations from a number in the file.
6412const MOST_DEGREE: usize = 64;
6413
6414/// A spline degree read from the file, `missing` where the argument is
6415/// absent, refused past [`MOST_DEGREE`] or where it is not a count.
6416fn file_degree(arg: Option<&Arg>, missing: f64) -> OgeomResult<usize> {
6417    let degree = arg.and_then(Arg::number).unwrap_or(missing);
6418    #[allow(clippy::cast_precision_loss)]
6419    if !degree.is_finite() || degree < 0.0 || degree > MOST_DEGREE as f64 {
6420        ogeom_bail!(Construction, "a spline of degree {degree}");
6421    }
6422    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6423    Ok(degree as usize)
6424}
6425
6426/// Knots written once each with their multiplicities, spelt out. A
6427/// multiplicity past one more than [`MOST_DEGREE`] is refused: no valid
6428/// spline repeats a knot more often than its order.
6429fn expanded_knots(mults: &[Arg], knots: &[Arg]) -> OgeomResult<Vec<f64>> {
6430    let mut out = Vec::new();
6431    for (m, k) in mults.iter().zip(knots) {
6432        let count = m.number().unwrap_or(1.0);
6433        #[allow(clippy::cast_precision_loss)]
6434        if !count.is_finite() || count < 0.0 || count > (MOST_DEGREE + 1) as f64 {
6435            ogeom_bail!(Construction, "a knot of multiplicity {count}");
6436        }
6437        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6438        out.extend(std::iter::repeat_n(
6439            k.number().unwrap_or(0.0),
6440            count as usize,
6441        ));
6442    }
6443    Ok(out)
6444}
6445
6446/// Which instances the reader has looked at.
6447///
6448/// One slot per possible id where the instance table has one, as it does
6449/// for the dense ids exporters write: the reader touches instances millions
6450/// of times, and a direct index beats hashing every touch. A file whose ids
6451/// are sparse (one instance numbered in the billions) gets a set instead of
6452/// a table that size.
6453enum Visited {
6454    Dense(Vec<bool>),
6455    Sparse(ogeom_core::FastSet<u64>),
6456}
6457
6458impl Visited {
6459    fn for_instances(instances: &Instances) -> Self {
6460        match instances {
6461            Instances::Dense(slots) => Self::Dense(vec![false; slots.len()]),
6462            Instances::Sparse(_) => Self::Sparse(ogeom_core::FastSet::default()),
6463        }
6464    }
6465
6466    fn mark(&mut self, id: u64) {
6467        match self {
6468            Self::Dense(slots) => {
6469                if let Some(slot) = usize::try_from(id).ok().and_then(|i| slots.get_mut(i)) {
6470                    *slot = true;
6471                }
6472            }
6473            Self::Sparse(seen) => {
6474                seen.insert(id);
6475            }
6476        }
6477    }
6478
6479    fn seen(&self, id: u64) -> bool {
6480        match self {
6481            Self::Dense(slots) => usize::try_from(id)
6482                .ok()
6483                .and_then(|i| slots.get(i).copied())
6484                .unwrap_or(false),
6485            Self::Sparse(seen) => seen.contains(&id),
6486        }
6487    }
6488}
6489
6490/// The reference in argument `i`, or 0 (which names no entity, so the lookup
6491/// that follows refuses it) where the argument is missing or not a reference.
6492/// A representation item's name: its `REPRESENTATION_ITEM` part's in a
6493/// complex instance, else its own first argument's.
6494fn item_name(instance: &Instance) -> String {
6495    instance
6496        .part("REPRESENTATION_ITEM")
6497        .or_else(|| instance.parts.first().map(|(_, args)| args.as_slice()))
6498        .and_then(<[Arg]>::first)
6499        .and_then(|arg| match arg {
6500            Arg::Str(name) => Some(name.clone()),
6501            _ => None,
6502        })
6503        .unwrap_or_default()
6504}
6505
6506fn ref_at(args: &[Arg], i: usize) -> u64 {
6507    args.get(i).and_then(Arg::reference).unwrap_or(0)
6508}
6509
6510#[cfg(test)]
6511#[allow(clippy::unwrap_used)]
6512mod tests {
6513    use super::*;
6514
6515    const T: Tolerances = Tolerances::millimetres();
6516
6517    /// A pcurve read at an affine map of the parameter is the same curve
6518    /// at the mapped parameter, for each kind and each way round: a line
6519    /// at a pace of one and at another, a circle reversed and turned, an
6520    /// ellipse turned a half turn, a spline reversed and slowed. Rounding
6521    /// in the turned frame's sines is all that stands between the two.
6522    #[test]
6523    fn a_pcurve_paced_is_the_same_curve_at_the_mapped_parameter() {
6524        use ogeom_math::{Direction2, Frame2, Point2};
6525        let frame = Frame2::new(Point2::new(1.0, -2.0), Direction2::from_angle(0.3));
6526        let curves: Vec<PlanarCurve> = vec![
6527            ogeom_geom::Line2d::over(
6528                ogeom_math::Axis2::new(Point2::new(0.5, 1.0), Direction2::from_angle(1.1)),
6529                -10.0,
6530                10.0,
6531            )
6532            .unwrap()
6533            .into(),
6534            ogeom_geom::Circle2d::new(ogeom_math::Circle2::new(frame, 2.0, T).unwrap()).into(),
6535            ogeom_geom::Ellipse2d::new(ogeom_math::Ellipse2::new(frame, 3.0, 1.0, T).unwrap())
6536                .into(),
6537            ogeom_geom::BSpline2d::new(
6538                KnotVector::new(vec![0.0, 0.0, 0.0, 2.0, 5.0, 5.0, 5.0], 2).unwrap(),
6539                vec![
6540                    Point2::new(0.0, 0.0),
6541                    Point2::new(1.0, 3.0),
6542                    Point2::new(4.0, 2.0),
6543                    Point2::new(6.0, -1.0),
6544                ],
6545                T,
6546            )
6547            .unwrap()
6548            .into(),
6549        ];
6550        let pi = core::f64::consts::PI;
6551        let maps: [&[(f64, f64)]; 4] = [
6552            &[(1.0, 2.5), (-1.0, 0.75), (0.4, -1.0)],
6553            &[(1.0, 0.7), (-1.0, 0.7), (-1.0, -2.0)],
6554            &[(1.0, pi), (-1.0, pi), (-1.0, 0.0)],
6555            &[(0.5, 1.0), (-2.0, 4.0)],
6556        ];
6557        for (curve, maps) in curves.iter().zip(maps) {
6558            for &(a, b) in maps {
6559                let paced = paced(curve, a, b, T).unwrap();
6560                let (lo, hi) = curve.domain();
6561                let ((from, to), at) = if curve.is_periodic() {
6562                    ((0.0, 6.0), 6)
6563                } else {
6564                    let (x, y) = ((lo - b) / a, (hi - b) / a);
6565                    ((x.min(y), x.max(y)), 8)
6566                };
6567                for i in 0..=at {
6568                    let t = from + (to - from) * f64::from(i) / f64::from(at);
6569                    let want = curve.point_at(a.mul_add(t, b), T).unwrap();
6570                    let got = paced.point_at(t, T).unwrap();
6571                    assert!(
6572                        got.distance(want) <= 1e-12,
6573                        "{curve:?} at {a}·t + {b}, t = {t}: {got:?} against {want:?}"
6574                    );
6575                }
6576            }
6577        }
6578        // An ellipse turns only by whole half turns, a conic only at a pace
6579        // of one.
6580        assert!(paced(&curves[2], 1.0, 0.5, T).is_none());
6581        assert!(paced(&curves[1], 2.0, 0.0, T).is_none());
6582    }
6583
6584    /// An edge's window between the feet of vertices standing on the
6585    /// curve short of its ends: taken in the curve's order, refused where
6586    /// the vertices are the ends already, run against the curve, or off it.
6587    #[test]
6588    fn an_edges_window_is_taken_between_its_vertices_feet() {
6589        let spline: Curve = BSplineCurve::new(
6590            KnotVector::clamped_uniform(3, 6).unwrap(),
6591            vec![
6592                Point::new(0.0, 0.0, 0.0),
6593                Point::new(1.0, 2.0, 0.5),
6594                Point::new(2.5, 1.0, -0.5),
6595                Point::new(4.0, 3.0, 1.0),
6596                Point::new(5.0, 0.5, 0.0),
6597                Point::new(6.0, 2.0, 2.0),
6598            ],
6599            T,
6600        )
6601        .unwrap()
6602        .into();
6603        let at = |t: f64| spline.point_at(t, T).unwrap();
6604        let (a, b) = window_between_feet(&spline, at(0.3), at(0.7), T).unwrap();
6605        assert!(
6606            (a - 0.3).abs() < 1e-9 && (b - 0.7).abs() < 1e-9,
6607            "{a} .. {b}"
6608        );
6609        let (a, b) = window_between_feet(&spline, at(0.0), at(0.7), T).unwrap();
6610        assert!(a.abs() < 1e-9 && (b - 0.7).abs() < 1e-9, "{a} .. {b}");
6611        assert!(window_between_feet(&spline, at(0.0), at(1.0), T).is_none());
6612        assert!(window_between_feet(&spline, at(0.7), at(0.3), T).is_none());
6613        let off = at(0.3) + Vector::new(0.0, 0.0, 0.5);
6614        assert!(window_between_feet(&spline, off, at(0.7), T).is_none());
6615    }
6616
6617    /// A parallel of a cone read as a circle a few tenths of a micron wider
6618    /// than the cone there, its range running past one turn: the exact
6619    /// pcurve is stated over the whole range, and the gap it stands off
6620    /// the circle is measured along all of it.
6621    #[test]
6622    fn a_wrapped_parallel_states_its_gap() {
6623        let cone: SurfaceGeometry = ConeSurface::new(
6624            Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap(),
6625            (-10.0, 10.0),
6626        )
6627        .unwrap()
6628        .into();
6629        let wider = 3e-7;
6630        let circle: Curve =
6631            CircleCurve::new(Circle::new(Frame::WORLD, 2.0 + wider, T).unwrap()).into();
6632        let range = (3.0, 8.5);
6633        let PreparedPcurve::Exact { curve, off } = derive_pcurve(&circle, range, &cone, &[], T)
6634        else {
6635            panic!("a parallel has an exact pcurve");
6636        };
6637        assert!(curve.point_at(range.1, T).is_ok(), "{curve:?}");
6638        assert!((off - wider).abs() < wider * 1e-3, "{off}");
6639    }
6640}