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.
13//!
14//! What the reader does not understand it *counts*: every instance never
15//! visited lands in the report's skipped table by keyword, and every
16//! compromise (a face without a pcurve, a shell that does not close) is a
17//! warning with the instance number in it. An import that succeeded with
18//! three warnings is a different thing from one that succeeded, and the
19//! report is what keeps the difference visible.
20
21use super::parse::{Arg, Exchange, Instance};
22use ogeom_algo::{
23    make_edge_between, make_face_on, make_shell, make_solid, make_vertex, make_wire,
24    project_on_curve,
25};
26use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
27use ogeom_geom::Curve2d as _;
28use ogeom_geom::Curve3d as _;
29use ogeom_geom::Surface as _;
30use ogeom_geom::Transformable as _;
31use ogeom_geom::{
32    BSplineCurve, CircleCurve, ConeSurface, Curve, CylinderSurface, EllipseCurve, ExtrusionSurface,
33    HyperbolaCurve, LineCurve, ParabolaCurve, PlanarCurve, PlaneSurface, SphereSurface,
34    SurfaceGeometry, TorusSurface,
35};
36use ogeom_math::{
37    Axis, Blend as _, Circle, Cone, Cylinder, Direction, Ellipse, Frame, Hyperbola, KnotVector,
38    Matrix3, Parabola, Plane, Point, Sphere, Torus, Transform, Vector, Weighted,
39};
40use ogeom_topo::{Location, Model, Shape};
41use std::collections::{BTreeMap, HashMap, HashSet};
42
43/// One use a bound makes of an edge: the edge as the loop walks it, the edge
44/// as it was built, the file's id for it, and the curve and range it carries.
45type BoundUse = (Shape, Shape, u64, Curve, (f64, f64));
46
47/// How a point of space reads on a surface's unbounded directions, `None`
48/// where the direction has none to read.
49type ChartReading = dyn Fn(Point) -> (Option<f64>, Option<f64>);
50
51/// How far a plane or a cylinder read from a file extends past what anything
52/// in the file uses. A face's trim is its wires; the surface's domain is only
53/// the parameter window, and this one is generous without being unbounded.
54const SURFACE_EXTENT: f64 = 1e5;
55
56/// What an import brought in, and what it left behind.
57#[derive(Debug, Default)]
58pub struct StepReport {
59    /// Millimetres per file unit, as the file's own unit section states it.
60    pub scale_mm: f64,
61    /// Instance keywords the reader never visited, with counts. Presentation,
62    /// product structure and annotation live here by design; geometry landing
63    /// here is a gap worth reading about.
64    pub skipped: BTreeMap<String, usize>,
65    /// Everything that imported less than perfectly, one line each.
66    pub warnings: Vec<String>,
67    /// The warning flood, counted: one entry per *kind* of imperfection,
68    /// with how often it happened, the worst measured value where the kind
69    /// measures one, and one exemplar entity id. A 776-warning community
70    /// file summarises to a handful of lines a consumer can actually show;
71    /// `warnings` keeps the full prose. Sorted by count, largest first.
72    pub summary: Vec<WarningSummary>,
73    /// Faces that read without a complete trim: an edge's boundary sat too
74    /// far from the surface for any honest pcurve (beyond the one-millimetre
75    /// healing cap), so the face will refuse to triangulate. Deduplicated,
76    /// in file order: the structured form of the warnings that name them,
77    /// carrying the face itself so the instructed follow-up needs no search.
78    /// `check` reports the same faces as broken from the model side. A
79    /// refused id whose face never finished building has nothing to act on
80    /// and stays in the warnings alone.
81    pub untrimmed_faces: Vec<UntrimmedFace>,
82}
83
84/// One kind of imperfect import, counted rather than repeated.
85#[derive(Debug, Clone)]
86pub struct WarningSummary {
87    /// The kind, stable across runs: `"vertex-miss"`, `"boundary-slop"`,
88    /// `"fit-short"`, `"untrimmed"`.
89    pub kind: &'static str,
90    /// How many times it happened.
91    pub count: usize,
92    /// The worst measured value among them: a distance, for every kind
93    /// that measures one; zero where none applies.
94    pub worst: f64,
95    /// One entity id to look at first.
96    pub exemplar: u64,
97}
98
99/// A face the reader could not trim, with the shape to act on.
100#[derive(Debug, Clone)]
101pub struct UntrimmedFace {
102    /// The file's id for the face: the same id the warnings name.
103    pub entity: u64,
104    /// The face as built; hand it to `ogeom_heal::fix_face_pcurves` with
105    /// the cap the situation deserves.
106    pub face: Shape,
107}
108
109/// A read exchange file: the model, the bodies found, and the report.
110#[derive(Debug)]
111pub struct StepImport {
112    /// The document everything was built into: the model, plus the file's
113    /// product structure, names and colours.
114    pub document: ogeom_doc::Document,
115    /// One shape per `MANIFOLD_SOLID_BREP` (`BREP_WITH_VOIDS` included,
116    /// its cavities among its shells), in file order.
117    pub solids: Vec<Shape>,
118    /// One shape per `SHELL_BASED_SURFACE_MODEL`, in file order: its shell,
119    /// or a compound of its shells when it names several.
120    ///
121    /// A surface body is what a modeller exports for a part built from faces
122    /// rather than from a solid. It stays a shell here even when it happens
123    /// to close: the file did not call it a solid, and the document carries
124    /// it under its product exactly as the file placed it.
125    pub shells: Vec<Shape>,
126    /// What happened along the way.
127    pub report: StepReport,
128}
129
130/// Read a STEP exchange file's B-rep content.
131///
132/// # Errors
133///
134/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the file does
135/// not parse, contains no solid, or a solid's structure is broken in a way
136/// topology cannot represent. Faces the reader cannot complete become
137/// warnings, not errors; the report says exactly what was compromised.
138pub fn read_step(text: &str, tol: Tolerances) -> OgeomResult<StepImport> {
139    let exchange = super::parse::parse(text)?;
140    let mut reader = Reader {
141        exchange: &exchange,
142        model: Model::new(),
143        report: StepReport {
144            scale_mm: 1.0,
145            ..StepReport::default()
146        },
147        angle_scale: 1.0,
148        visited: {
149            let top = exchange.data.keys().max().copied().unwrap_or(0);
150            let top = usize::try_from(top).unwrap_or(usize::MAX - 1);
151            vec![false; top + 1]
152        },
153        vertices: HashMap::new(),
154        edges: HashMap::new(),
155        faces: HashMap::new(),
156        callout_index: HashMap::new(),
157        pcurves: HashMap::new(),
158        untrimmed_ids: Vec::new(),
159        tallies: HashMap::new(),
160        cdsr_of_nauo: None,
161        properties_of_definition: None,
162        sdrs_of_property: None,
163        tol,
164    };
165    reader.report.scale_mm = reader.unit_scale();
166    reader.angle_scale = reader.angle_unit_scale();
167
168    let mut solids = Vec::new();
169    let mut shells = Vec::new();
170    let mut by_item: HashMap<u64, Shape> = HashMap::new();
171    // Solids and surface models, one walk in file order; a body is a body
172    // to the progress bar whichever kind it is.
173    let mut ids: Vec<(u64, bool)> = exchange
174        .data
175        .iter()
176        .filter_map(|(id, inst)| {
177            if inst.part("MANIFOLD_SOLID_BREP").is_some() || inst.part("BREP_WITH_VOIDS").is_some()
178            {
179                Some((*id, true))
180            } else if inst.part("SHELL_BASED_SURFACE_MODEL").is_some() {
181                Some((*id, false))
182            } else {
183                None
184            }
185        })
186        .collect();
187    ids.sort_unstable();
188    let total = ids.len() as u64;
189    for (done, (id, is_solid)) in ids.into_iter().enumerate() {
190        ogeom_core::progress::checkpoint()?;
191        ogeom_core::progress::stage_at("step: solid", done as u64 + 1, total);
192        if is_solid {
193            let solid = reader.solid(id)?;
194            by_item.insert(id, solid.clone());
195            solids.push(solid);
196        } else {
197            let shell = reader.surface_model(id)?;
198            by_item.insert(id, shell.clone());
199            shells.push(shell);
200        }
201    }
202    // Every widening the reader made (an edge's tolerance recording how far
203    // its pcurves sit from its curve) reaches the vertices that bound it,
204    // and a face's reaches its edges: the containment rule the checker holds
205    // a solid to, kept by the reader that built it.
206    for body in solids.iter().chain(shells.iter()) {
207        ogeom_algo::restore_containment(&mut reader.model, body)?;
208    }
209    // The tallies fold into the summary, largest first, ties by kind so the
210    // order is the file's and not the map's.
211    {
212        let mut entries: Vec<WarningSummary> = reader
213            .tallies
214            .drain()
215            .map(|(kind, (count, worst, exemplar))| WarningSummary {
216                kind,
217                count,
218                worst,
219                exemplar,
220            })
221            .collect();
222        entries.sort_by(|a, b| b.count.cmp(&a.count).then(a.kind.cmp(b.kind)));
223        reader.report.summary = entries;
224    }
225    // The noted refusals resolve to the faces themselves, now that they
226    // exist: the same cache the shells were assembled from answers by the
227    // very id the warnings name.
228    for id in std::mem::take(&mut reader.untrimmed_ids) {
229        if let Some(face) = reader.faces.get(&id) {
230            reader.report.untrimmed_faces.push(UntrimmedFace {
231                entity: id,
232                face: face.clone(),
233            });
234        }
235    }
236    if solids.is_empty() && shells.is_empty() {
237        ogeom_bail!(
238            Construction,
239            "the exchange file contains no MANIFOLD_SOLID_BREP, \
240             BREP_WITH_VOIDS or SHELL_BASED_SURFACE_MODEL to read"
241        );
242    }
243    let document = reader.document(&by_item, &solids, &shells)?;
244
245    // Everything never visited, counted by its leading keyword.
246    for (id, instance) in &exchange.data {
247        if !usize::try_from(*id)
248            .ok()
249            .and_then(|i| reader.visited.get(i).copied())
250            .unwrap_or(false)
251        {
252            *reader
253                .report
254                .skipped
255                .entry(instance.keyword().to_owned())
256                .or_default() += 1;
257        }
258    }
259
260    Ok(StepImport {
261        document,
262        solids,
263        shells,
264        report: reader.report,
265    })
266}
267
268/// An edge as built: the shape, its curve and range, and whether STEP's edge
269/// direction runs against the curve.
270type BuiltEdge = (Shape, Curve, (f64, f64), bool);
271
272/// A pcurve derived ahead of the face that needs it.
273///
274/// Deriving one is a pure function of a curve, its range and a surface (no
275/// model, no order), and on a real assembly it is 95% of the time spent
276/// building solids. So it is done for a whole solid at once, off the walk
277/// that attaches it.
278enum PreparedPcurve {
279    /// The projection had a closed form.
280    Exact(PlanarCurve),
281    /// It did not, and this is the fit, with what the fit cost.
282    Fitted {
283        curve: PlanarCurve,
284        error: f64,
285        met: bool,
286        worst_off: f64,
287        warning: Option<String>,
288    },
289    /// Neither worked; the face gets the warning the walk would have made.
290    Refused(String),
291}
292
293struct Reader<'a> {
294    exchange: &'a Exchange,
295    model: Model,
296    report: StepReport,
297    /// Radians per file angle unit; degrees are common.
298    angle_scale: f64,
299    /// One slot per possible instance id: the reader touches instances
300    /// millions of times, and a direct index beats hashing every touch.
301    visited: Vec<bool>,
302    vertices: HashMap<u64, Shape>,
303    edges: HashMap<u64, BuiltEdge>,
304    faces: HashMap<u64, Shape>,
305    /// STEP id → index into the callouts just built, for the views pass.
306    callout_index: HashMap<u64, usize>,
307    /// `(face, edge)` → the pcurve already derived for it, from the parallel
308    /// pass at the head of each solid.
309    pcurves: HashMap<(u64, u64), PreparedPcurve>,
310    /// Faces noted untrimmed, by file id; resolved to shapes once the read
311    /// is far enough along for the shapes to exist.
312    untrimmed_ids: Vec<u64>,
313    /// kind → (count, worst, exemplar), folded into the report's summary.
314    tallies: HashMap<&'static str, (usize, f64, u64)>,
315    /// Usage → its `CONTEXT_DEPENDENT_SHAPE_REPRESENTATION`, built once.
316    /// The lookup used to rescan the whole exchange per assembly edge:
317    /// O(usages × entities), 333 × 457 k on one reporting assembly, which
318    /// was three quarters of the entire document build.
319    cdsr_of_nauo: Option<HashMap<u64, u64>>,
320    /// Definition → its `PROPERTY_DEFINITION`s, and property → its
321    /// `SHAPE_DEFINITION_REPRESENTATION`s, built together once. The datum
322    /// target lookup used to rescan the whole exchange per property *per
323    /// target*: the same quadratic shape the assembly index retired, one
324    /// storey deeper. Each list ascends by id, so whichever entry answers
325    /// is the one the old scan would have reached first.
326    properties_of_definition: Option<HashMap<u64, Vec<u64>>>,
327    sdrs_of_property: Option<HashMap<u64, Vec<u64>>>,
328    tol: Tolerances,
329}
330
331impl Reader<'_> {
332    fn instance(&mut self, id: u64) -> OgeomResult<&'_ Instance> {
333        if let Ok(i) = usize::try_from(id)
334            && let Some(slot) = self.visited.get_mut(i)
335        {
336            *slot = true;
337        }
338        self.exchange.data.get(&id).ok_or_else(|| {
339            ogeom_core::ogeom_err!(
340                Construction,
341                "the file references #{id}, which does not exist"
342            )
343        })
344    }
345
346    /// A face's arguments (name, bounds, surface, sense), whether the file
347    /// wrote it as the `ADVANCED_FACE` every modern writer uses or as the
348    /// plain `FACE_SURFACE` it specialises, which carries the same four.
349    fn face_args(&mut self, id: u64) -> OgeomResult<Vec<Arg>> {
350        let instance = self.instance(id)?;
351        if let Some(args) = instance
352            .part("ADVANCED_FACE")
353            .or_else(|| instance.part("FACE_SURFACE"))
354        {
355            return Ok(args.to_vec());
356        }
357        ogeom_bail!(
358            Construction,
359            "#{id} is {}, where a face (ADVANCED_FACE or FACE_SURFACE) was needed",
360            instance.keyword()
361        );
362    }
363
364    fn args(&mut self, id: u64, keyword: &str) -> OgeomResult<Vec<Arg>> {
365        let instance = self.instance(id)?;
366        let Some(args) = instance.part(keyword) else {
367            ogeom_bail!(
368                Construction,
369                "#{id} is {}, where {keyword} was needed",
370                instance.keyword()
371            );
372        };
373        Ok(args.to_vec())
374    }
375
376    // --- units ---------------------------------------------------------------
377
378    /// The unit instances the representation context actually assigns.
379    ///
380    /// A file may carry both a radian and a degree (definition and
381    /// conversion), and which applies is not a matter of existence but of
382    /// assignment: `GLOBAL_UNIT_ASSIGNED_CONTEXT` lists the ones in force.
383    fn assigned_units(&self) -> Vec<u64> {
384        // A file may carry several unit contexts (inches for the model,
385        // millimetres for its annotation sheet), and the shapes' own
386        // representations name the one their coordinates mean. That context
387        // goes first; the rest follow in entity order, so the answer never
388        // depends on how a map happens to iterate.
389        let mut cited: Vec<u64> = self
390            .exchange
391            .data
392            .values()
393            .filter(|inst| {
394                inst.parts
395                    .iter()
396                    .any(|(k, _)| k.ends_with("SHAPE_REPRESENTATION"))
397            })
398            .filter_map(|inst| {
399                inst.parts
400                    .iter()
401                    .find(|(k, _)| k.ends_with("SHAPE_REPRESENTATION"))
402                    .and_then(|(_, args)| args.last())
403                    .and_then(Arg::reference)
404            })
405            .collect();
406        cited.sort_unstable();
407        cited.dedup();
408
409        let mut contexts: Vec<u64> = self
410            .exchange
411            .data
412            .iter()
413            .filter(|(_, inst)| inst.part("GLOBAL_UNIT_ASSIGNED_CONTEXT").is_some())
414            .map(|(id, _)| *id)
415            .collect();
416        contexts.sort_unstable();
417        contexts.sort_by_cached_key(|id| !cited.contains(id));
418
419        let mut out = Vec::new();
420        for id in contexts {
421            if let Some(instance) = self.exchange.data.get(&id)
422                && let Some(args) = instance.part("GLOBAL_UNIT_ASSIGNED_CONTEXT")
423            {
424                for arg in args.iter().filter_map(Arg::list).flatten() {
425                    if let Some(r) = arg.reference() {
426                        out.push(r);
427                    }
428                }
429            }
430        }
431        out
432    }
433
434    /// Millimetres per file length unit, from the units the context assigns.
435    fn unit_scale(&mut self) -> f64 {
436        let assigned = self.assigned_units();
437        for id in assigned {
438            let Some(instance) = self.exchange.data.get(&id) else {
439                continue;
440            };
441            let instance = instance.clone();
442            if instance.part("LENGTH_UNIT").is_none() {
443                continue;
444            }
445            if let Some(args) = instance.part("SI_UNIT") {
446                return match args.first() {
447                    Some(Arg::Enum(prefix)) => match prefix.as_str() {
448                        "MILLI" => 1.0,
449                        "CENTI" => 10.0,
450                        "DECI" => 100.0,
451                        "KILO" => 1e6,
452                        "MICRO" => 1e-3,
453                        other => {
454                            self.report
455                                .warnings
456                                .push(format!("#{id}: unknown SI prefix {other}; taking metres"));
457                            1000.0
458                        }
459                    },
460                    _ => 1000.0,
461                };
462            }
463            if let Some(args) = instance.part("CONVERSION_BASED_UNIT")
464                && let Some(measure) = args.get(1).and_then(Arg::reference)
465                && let Some(inner) = self.exchange.data.get(&measure)
466            {
467                let factor = inner
468                    .parts
469                    .iter()
470                    .flat_map(|(_, a)| a.iter())
471                    .find_map(|a| match a {
472                        Arg::Typed(k, v) if k == "LENGTH_MEASURE" => {
473                            v.first().and_then(Arg::number)
474                        }
475                        _ => None,
476                    });
477                let base = inner
478                    .parts
479                    .iter()
480                    .flat_map(|(_, a)| a.iter())
481                    .find_map(Arg::reference)
482                    .and_then(|b| self.exchange.data.get(&b))
483                    .and_then(|u| u.part("SI_UNIT"))
484                    .map_or(1000.0, |args| match args.first() {
485                        Some(Arg::Enum(p)) if p == "MILLI" => 1.0,
486                        Some(Arg::Enum(p)) if p == "CENTI" => 10.0,
487                        _ => 1000.0,
488                    });
489                if let Some(f) = factor {
490                    return f * base;
491                }
492            }
493        }
494        self.report
495            .warnings
496            .push("no length unit found; taking millimetres".to_owned());
497        1.0
498    }
499
500    /// Radians per file angle unit: the same dance as length, for the files
501    /// that measure their cones in degrees.
502    fn angle_unit_scale(&mut self) -> f64 {
503        let assigned = self.assigned_units();
504        for id in assigned {
505            let Some(instance) = self.exchange.data.get(&id) else {
506                continue;
507            };
508            let instance = instance.clone();
509            if instance.part("PLANE_ANGLE_UNIT").is_none() {
510                continue;
511            }
512            if instance.part("SI_UNIT").is_some() {
513                return 1.0;
514            }
515            if let Some(args) = instance.part("CONVERSION_BASED_UNIT")
516                && let Some(measure) = args.get(1).and_then(Arg::reference)
517                && let Some(inner) = self.exchange.data.get(&measure)
518            {
519                let factor = inner
520                    .parts
521                    .iter()
522                    .flat_map(|(_, a)| a.iter())
523                    .find_map(|a| match a {
524                        Arg::Typed(k, v) if k == "PLANE_ANGLE_MEASURE" => {
525                            v.first().and_then(Arg::number)
526                        }
527                        _ => None,
528                    });
529                if let Some(f) = factor {
530                    return f;
531                }
532            }
533        }
534        1.0
535    }
536
537    // --- geometry ------------------------------------------------------------
538
539    fn point(&mut self, id: u64) -> OgeomResult<Point> {
540        let args = self.args(id, "CARTESIAN_POINT")?;
541        let Some(coords) = args.get(1).and_then(Arg::list) else {
542            ogeom_bail!(Construction, "#{id}: a point without coordinates");
543        };
544        let scale = self.report.scale_mm;
545        let value = |i: usize| coords.get(i).and_then(Arg::number).unwrap_or(0.0) * scale;
546        Ok(Point::new(value(0), value(1), value(2)))
547    }
548
549    fn direction(&mut self, id: u64) -> OgeomResult<Direction> {
550        let args = self.args(id, "DIRECTION")?;
551        let Some(coords) = args.get(1).and_then(Arg::list) else {
552            ogeom_bail!(Construction, "#{id}: a direction without components");
553        };
554        let value = |i: usize| coords.get(i).and_then(Arg::number).unwrap_or(0.0);
555        Direction::new(Vector::new(value(0), value(1), value(2)), self.tol)
556    }
557
558    /// An `AXIS2_PLACEMENT_3D` as a frame, with the standard's defaults for
559    /// what the file leaves out.
560    fn frame(&mut self, id: u64) -> OgeomResult<Frame> {
561        let args = self.args(id, "AXIS2_PLACEMENT_3D")?;
562        let origin = args
563            .get(1)
564            .and_then(Arg::reference)
565            .map(|r| self.point(r))
566            .transpose()?
567            .unwrap_or(Point::ORIGIN);
568        let z = args
569            .get(2)
570            .and_then(Arg::reference)
571            .map(|r| self.direction(r))
572            .transpose()?
573            .unwrap_or(Direction::Z);
574        let x = match args.get(3).and_then(Arg::reference) {
575            Some(r) => self.direction(r)?,
576            None => Direction::from_cross(z.vector(), Vector::new(0.31, 0.52, 0.8), self.tol)?,
577        };
578        Frame::new(origin, z, x, self.tol)
579    }
580
581    fn surface(&mut self, id: u64) -> OgeomResult<Option<SurfaceGeometry>> {
582        let (keyword, args) = {
583            let instance = self.instance(id)?;
584            (
585                instance.keyword().to_owned(),
586                instance
587                    .parts
588                    .first()
589                    .map(|(_, a)| a.clone())
590                    .unwrap_or_default(),
591            )
592        };
593        let scale = self.report.scale_mm;
594        let radius_arg = |args: &[Arg], i: usize| args.get(i).and_then(Arg::number);
595        // B-spline surfaces arrive two ways: a simple instance with every
596        // attribute in one list, or a complex instance whose parts each
597        // carry their own slice, the rational form always the latter.
598        {
599            let (base, knots_part, weights) = {
600                let instance = self.instance(id)?;
601                (
602                    instance.part("B_SPLINE_SURFACE").map(<[Arg]>::to_vec),
603                    instance
604                        .part("B_SPLINE_SURFACE_WITH_KNOTS")
605                        .map(<[Arg]>::to_vec),
606                    instance
607                        .part("RATIONAL_B_SPLINE_SURFACE")
608                        .map(<[Arg]>::to_vec),
609                )
610            };
611            if let Some(kp) = knots_part {
612                let (degrees, grid_arg, mults_knots) = if let Some(base) = base {
613                    (
614                        (base.first().cloned(), base.get(1).cloned()),
615                        base.get(2).cloned(),
616                        (
617                            kp.first().cloned(),
618                            kp.get(1).cloned(),
619                            kp.get(2).cloned(),
620                            kp.get(3).cloned(),
621                        ),
622                    )
623                } else {
624                    (
625                        (kp.get(1).cloned(), kp.get(2).cloned()),
626                        kp.get(3).cloned(),
627                        (
628                            kp.get(8).cloned(),
629                            kp.get(9).cloned(),
630                            kp.get(10).cloned(),
631                            kp.get(11).cloned(),
632                        ),
633                    )
634                };
635                return self
636                    .bspline_surface(id, degrees, grid_arg, mults_knots, weights, None)
637                    .map(Some);
638            }
639        }
640        // A complex instance whose knots are implied by its form, or a
641        // simple one of those forms.
642        {
643            let (base, weights, form) = {
644                let instance = self.instance(id)?;
645                (
646                    instance.part("B_SPLINE_SURFACE").map(<[Arg]>::to_vec),
647                    instance
648                        .part("RATIONAL_B_SPLINE_SURFACE")
649                        .map(<[Arg]>::to_vec),
650                    ["BEZIER_SURFACE", "UNIFORM_SURFACE", "QUASI_UNIFORM_SURFACE"]
651                        .into_iter()
652                        .find(|k| instance.part(k).is_some()),
653                )
654            };
655            if let (Some(base), Some(form)) = (base, form) {
656                return self
657                    .bspline_surface(
658                        id,
659                        (base.first().cloned(), base.get(1).cloned()),
660                        base.get(2).cloned(),
661                        (None, None, None, None),
662                        weights,
663                        Some(form),
664                    )
665                    .map(Some);
666            }
667            if let Some(form) = ["BEZIER_SURFACE", "UNIFORM_SURFACE", "QUASI_UNIFORM_SURFACE"]
668                .into_iter()
669                .find(|k| *k == keyword)
670            {
671                return self
672                    .bspline_surface(
673                        id,
674                        (args.get(1).cloned(), args.get(2).cloned()),
675                        args.get(3).cloned(),
676                        (None, None, None, None),
677                        None,
678                        Some(form),
679                    )
680                    .map(Some);
681            }
682        }
683        let out = match keyword.as_str() {
684            "PLANE" => {
685                let frame = self.frame(args[1].reference().unwrap_or(0))?;
686                Some(
687                    PlaneSurface::over(
688                        Plane::new(frame),
689                        (-SURFACE_EXTENT, SURFACE_EXTENT),
690                        (-SURFACE_EXTENT, SURFACE_EXTENT),
691                    )?
692                    .into(),
693                )
694            }
695            "CYLINDRICAL_SURFACE" => {
696                let frame = self.frame(args[1].reference().unwrap_or(0))?;
697                let radius = radius_arg(&args, 2).unwrap_or(0.0) * scale;
698                Some(
699                    CylinderSurface::new(
700                        Cylinder::new(frame, radius, self.tol)?,
701                        (-SURFACE_EXTENT, SURFACE_EXTENT),
702                    )?
703                    .into(),
704                )
705            }
706            "CONICAL_SURFACE" => {
707                let frame = self.frame(args[1].reference().unwrap_or(0))?;
708                let radius = radius_arg(&args, 2).unwrap_or(0.0) * scale;
709                let angle = radius_arg(&args, 3).unwrap_or(0.0) * self.angle_scale;
710                Some(
711                    ConeSurface::new(
712                        Cone::new(frame, radius, angle, self.tol)?,
713                        (-SURFACE_EXTENT, SURFACE_EXTENT),
714                    )?
715                    .into(),
716                )
717            }
718            "SPHERICAL_SURFACE" => {
719                let frame = self.frame(args[1].reference().unwrap_or(0))?;
720                let radius = radius_arg(&args, 2).unwrap_or(0.0) * scale;
721                Some(SphereSurface::new(Sphere::new(frame, radius, self.tol)?).into())
722            }
723            "TOROIDAL_SURFACE" => {
724                let frame = self.frame(args[1].reference().unwrap_or(0))?;
725                let major = radius_arg(&args, 2).unwrap_or(0.0) * scale;
726                let minor = radius_arg(&args, 3).unwrap_or(0.0) * scale;
727                Some(TorusSurface::new(Torus::new(frame, major, minor, self.tol)?).into())
728            }
729            "SURFACE_OF_LINEAR_EXTRUSION" => {
730                // A curve swept along a vector, unbounded either way. A
731                // file writes a drum's wall this way as often as it writes a
732                // cylinder (a circle swept along its own axis) and a wall
733                // as a line swept: those are read as the cylinder and the
734                // plane they are, exact and known everywhere downstream.
735                // Anything else sweeps as itself, over a window the face's
736                // own edges then widen to fit.
737                let Some(curve) = self.curve(args[1].reference().unwrap_or(0))? else {
738                    self.report.warnings.push(format!(
739                        "#{id}: an extrusion's swept curve is not read; its face is skipped"
740                    ));
741                    return Ok(None);
742                };
743                let vector = self.args(args[2].reference().unwrap_or(0), "VECTOR")?;
744                let direction = self.direction(vector[1].reference().unwrap_or(0))?;
745                // Parallel to the file's own precision in directions: a
746                // writer states an axis to nine digits, a whisker off the
747                // sweep it meant to be exactly along.
748                let parallel =
749                    |axis: Direction| axis.vector().cross(direction.vector()).magnitude() <= 1e-6;
750                match &curve {
751                    Curve::Circle(c) if parallel(c.circle().frame().z()) => Some(
752                        CylinderSurface::new(
753                            Cylinder::new(c.circle().frame(), c.circle().radius(), self.tol)?,
754                            (-SURFACE_EXTENT, SURFACE_EXTENT),
755                        )?
756                        .into(),
757                    ),
758                    Curve::Line(l) if !parallel(l.axis().direction) => {
759                        let axis = l.axis();
760                        let normal = Direction::from_cross(
761                            axis.direction.vector(),
762                            direction.vector(),
763                            self.tol,
764                        )?;
765                        let frame = Frame::new(axis.location, normal, axis.direction, self.tol)?;
766                        Some(
767                            PlaneSurface::over(
768                                Plane::new(frame),
769                                (-SURFACE_EXTENT, SURFACE_EXTENT),
770                                (-SURFACE_EXTENT, SURFACE_EXTENT),
771                            )?
772                            .into(),
773                        )
774                    }
775                    _ => Some(
776                        ExtrusionSurface::over(
777                            curve,
778                            direction,
779                            (-SURFACE_EXTENT, SURFACE_EXTENT),
780                        )?
781                        .into(),
782                    ),
783                }
784            }
785            "SURFACE_OF_REVOLUTION" => {
786                let Some(curve) = self.curve(args[1].reference().unwrap_or(0))? else {
787                    self.report.warnings.push(format!(
788                        "#{id}: a revolution's swept curve is not read; its face is skipped"
789                    ));
790                    return Ok(None);
791                };
792                let placement = self.args(args[2].reference().unwrap_or(0), "AXIS1_PLACEMENT")?;
793                let location = self.point(placement[1].reference().unwrap_or(0))?;
794                let direction = match placement.get(2).and_then(Arg::reference) {
795                    Some(r) => self.direction(r)?,
796                    None => Direction::Z,
797                };
798                Some(
799                    ogeom_geom::RevolutionSurface::new(
800                        curve,
801                        Axis {
802                            location,
803                            direction,
804                        },
805                        core::f64::consts::TAU,
806                    )?
807                    .into(),
808                )
809            }
810            "OFFSET_SURFACE" => {
811                let Some(basis) = self.surface(args[1].reference().unwrap_or(0))? else {
812                    return Ok(None);
813                };
814                let distance = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
815                if distance == 0.0 {
816                    return Ok(Some(basis));
817                }
818                // An analytic basis offsets to the analytic surface it is,
819                // which every downstream path speaks exactly.
820                let offset = ogeom_geom::OffsetSurface::new(basis, distance)?;
821                Some(match offset.analytic(self.tol)? {
822                    Some(analytic) => analytic,
823                    None => SurfaceGeometry::Offset(Box::new(offset)),
824                })
825            }
826            // A face's own edges bound it; the window these name is the
827            // basis's, restated.
828            "RECTANGULAR_TRIMMED_SURFACE" | "CURVE_BOUNDED_SURFACE" => {
829                self.surface(args[1].reference().unwrap_or(0))?
830            }
831            "DEGENERATE_TOROIDAL_SURFACE" => {
832                let frame = self.frame(args[1].reference().unwrap_or(0))?;
833                let major = radius_arg(&args, 2).unwrap_or(0.0) * scale;
834                let minor = radius_arg(&args, 3).unwrap_or(0.0) * scale;
835                Some(TorusSurface::new(Torus::new(frame, major, minor, self.tol)?).into())
836            }
837            "RECTANGULAR_COMPOSITE_SURFACE" => Some(self.composite_surface(id, &args)?),
838            "SURFACE_REPLICA" => {
839                let Some(parent) = self.surface(args[1].reference().unwrap_or(0))? else {
840                    return Ok(None);
841                };
842                let motion = self.transformation_operator(args[2].reference().unwrap_or(0))?;
843                Some(parent.transformed(&motion, self.tol)?)
844            }
845            other => {
846                self.report.warnings.push(format!(
847                    "#{id}: surface kind {other} is not read yet; its face is skipped"
848                ));
849                None
850            }
851        };
852        Ok(out)
853    }
854
855    /// Expand STEP's multiplicity-compressed knots.
856    fn expand_knots(mults: Option<Arg>, knots: Option<Arg>) -> Vec<f64> {
857        let mut out = Vec::new();
858        let (Some(Arg::List(mults)), Some(Arg::List(knots))) = (mults, knots) else {
859            return out;
860        };
861        for (m, k) in mults.iter().zip(&knots) {
862            let count = m.number().unwrap_or(1.0);
863            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
864            let count = count as usize;
865            for _ in 0..count {
866                out.push(k.number().unwrap_or(0.0));
867            }
868        }
869        out
870    }
871
872    #[allow(clippy::type_complexity)]
873    fn bspline_surface(
874        &mut self,
875        id: u64,
876        degrees: (Option<Arg>, Option<Arg>),
877        grid_arg: Option<Arg>,
878        mults_knots: (Option<Arg>, Option<Arg>, Option<Arg>, Option<Arg>),
879        weights: Option<Vec<Arg>>,
880        form: Option<&str>,
881    ) -> OgeomResult<SurfaceGeometry> {
882        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
883        let deg = |a: Option<Arg>| a.and_then(|x| x.number()).unwrap_or(1.0) as usize;
884        let (u_degree, v_degree) = (deg(degrees.0), deg(degrees.1));
885        let Some(Arg::List(rows)) = grid_arg else {
886            ogeom_bail!(
887                Construction,
888                "#{id}: a b-spline surface without control points"
889            );
890        };
891        let mut points = Vec::new();
892        let (mut u_count, mut v_count) = (0, 0);
893        for row in &rows {
894            let Some(cells) = row.list() else { continue };
895            u_count += 1;
896            v_count = cells.len();
897            for cell in cells {
898                points.push(self.point(cell.reference().unwrap_or(0))?);
899            }
900        }
901        let (u_raw, v_raw) = match form {
902            Some(form) => (
903                implied_knots(form, u_degree, u_count),
904                implied_knots(form, v_degree, v_count),
905            ),
906            None => (
907                Self::expand_knots(mults_knots.0, mults_knots.2),
908                Self::expand_knots(mults_knots.1, mults_knots.3),
909            ),
910        };
911        let u_knots = KnotVector::new(u_raw, u_degree)?;
912        let v_knots = KnotVector::new(v_raw, v_degree)?;
913        let surface = if let Some(weights) = weights {
914            let flat: Vec<f64> = weights
915                .first()
916                .and_then(Arg::list)
917                .unwrap_or(&[])
918                .iter()
919                .filter_map(Arg::list)
920                .flatten()
921                .filter_map(Arg::number)
922                .collect();
923            let weighted: Vec<ogeom_math::Weighted<Point>> = points
924                .iter()
925                .zip(flat.iter().chain(std::iter::repeat(&1.0)))
926                .map(|(p, w)| ogeom_math::Weighted::new(*p, *w, self.tol))
927                .collect::<OgeomResult<_>>()?;
928            ogeom_geom::BSplineSurface::rational(
929                u_knots,
930                v_knots,
931                ogeom_math::ControlGrid::new(weighted, u_count, v_count)?,
932            )?
933        } else {
934            ogeom_geom::BSplineSurface::new(
935                u_knots,
936                v_knots,
937                &ogeom_math::ControlGrid::new(points, u_count, v_count)?,
938                self.tol,
939            )?
940        };
941        Ok(surface.into())
942    }
943
944    fn curve(&mut self, id: u64) -> OgeomResult<Option<Curve>> {
945        let (keyword, args) = {
946            let instance = self.instance(id)?;
947            (
948                instance.keyword().to_owned(),
949                instance
950                    .parts
951                    .first()
952                    .map(|(_, a)| a.clone())
953                    .unwrap_or_default(),
954            )
955        };
956        let scale = self.report.scale_mm;
957        {
958            let (base, kp, weights) = {
959                let instance = self.instance(id)?;
960                (
961                    instance.part("B_SPLINE_CURVE").map(<[Arg]>::to_vec),
962                    instance
963                        .part("B_SPLINE_CURVE_WITH_KNOTS")
964                        .map(<[Arg]>::to_vec),
965                    instance
966                        .part("RATIONAL_B_SPLINE_CURVE")
967                        .map(<[Arg]>::to_vec),
968                )
969            };
970            // A complex instance: the base part carries degree and control
971            // points; the knots come from the knots part or are implied by
972            // the form (Bézier, uniform, quasi-uniform); weights from the
973            // rational part where there is one.
974            let form = {
975                let instance = self.instance(id)?;
976                ["BEZIER_CURVE", "UNIFORM_CURVE", "QUASI_UNIFORM_CURVE"]
977                    .into_iter()
978                    .find(|k| instance.part(k).is_some())
979            };
980            if let Some(base) = base
981                && (kp.is_some() || form.is_some())
982            {
983                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
984                let degree = base.first().and_then(Arg::number).unwrap_or(1.0) as usize;
985                let control: Vec<Point> = base
986                    .get(1)
987                    .and_then(Arg::list)
988                    .unwrap_or(&[])
989                    .iter()
990                    .filter_map(Arg::reference)
991                    .map(|r| self.point(r))
992                    .collect::<OgeomResult<_>>()?;
993                let knots = match (&kp, form) {
994                    (Some(kp), _) => Self::expand_knots(kp.first().cloned(), kp.get(1).cloned()),
995                    (None, Some(form)) => implied_knots(form, degree, control.len()),
996                    (None, None) => unreachable!("guarded above"),
997                };
998                let knots = KnotVector::new(knots, degree)?;
999                let flat: Vec<f64> = weights
1000                    .as_ref()
1001                    .and_then(|w| w.first())
1002                    .and_then(Arg::list)
1003                    .unwrap_or(&[])
1004                    .iter()
1005                    .filter_map(Arg::number)
1006                    .collect();
1007                let weighted: Vec<ogeom_math::Weighted<Point>> = control
1008                    .iter()
1009                    .zip(flat.iter().chain(std::iter::repeat(&1.0)))
1010                    .map(|(p, w)| ogeom_math::Weighted::new(*p, *w, self.tol))
1011                    .collect::<OgeomResult<_>>()?;
1012                return Ok(Some(BSplineCurve::rational(knots, weighted)?.into()));
1013            }
1014        }
1015        let out: Option<Curve> = match keyword.as_str() {
1016            "LINE" => {
1017                let through = self.point(args[1].reference().unwrap_or(0))?;
1018                // The vector's magnitude scales STEP's parameter; ranges here
1019                // are re-derived from vertex geometry, so only the direction
1020                // matters.
1021                let vector = self.args(args[2].reference().unwrap_or(0), "VECTOR")?;
1022                let direction = self.direction(vector[1].reference().unwrap_or(0))?;
1023                Some(
1024                    LineCurve::new(Axis {
1025                        location: through,
1026                        direction,
1027                    })
1028                    .into(),
1029                )
1030            }
1031            "CIRCLE" => {
1032                let frame = self.frame(args[1].reference().unwrap_or(0))?;
1033                let radius = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
1034                Some(CircleCurve::new(Circle::new(frame, radius, self.tol)?).into())
1035            }
1036            "ELLIPSE" => {
1037                let frame = self.frame(args[1].reference().unwrap_or(0))?;
1038                let a = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
1039                let b = args.get(3).and_then(Arg::number).unwrap_or(0.0) * scale;
1040                Some(EllipseCurve::new(Ellipse::new(frame, a, b, self.tol)?).into())
1041            }
1042            "B_SPLINE_CURVE_WITH_KNOTS" => {
1043                let degree = args.get(1).and_then(Arg::number).unwrap_or(0.0);
1044                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1045                let degree = degree as usize;
1046                let control: Vec<Point> = args
1047                    .get(2)
1048                    .and_then(Arg::list)
1049                    .unwrap_or(&[])
1050                    .iter()
1051                    .filter_map(Arg::reference)
1052                    .map(|r| self.point(r))
1053                    .collect::<OgeomResult<_>>()?;
1054                let mults = args.get(6).and_then(Arg::list).unwrap_or(&[]).to_vec();
1055                let knots = args.get(7).and_then(Arg::list).unwrap_or(&[]).to_vec();
1056                let mut expanded = Vec::new();
1057                for (m, k) in mults.iter().zip(&knots) {
1058                    let count = m.number().unwrap_or(1.0);
1059                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1060                    let count = count as usize;
1061                    for _ in 0..count {
1062                        expanded.push(k.number().unwrap_or(0.0));
1063                    }
1064                }
1065                Some(
1066                    BSplineCurve::new(KnotVector::new(expanded, degree)?, control, self.tol)?
1067                        .into(),
1068                )
1069            }
1070            "SURFACE_CURVE" | "SEAM_CURVE" | "INTERSECTION_CURVE" => {
1071                // A curve dressed in its surface associations: what every
1072                // exporter derived from the reference kernel writes for
1073                // every edge. The 3D curve is the first argument; the
1074                // pcurve list is advisory and this reader re-derives its
1075                // own, so unwrapping is the whole job.
1076                self.curve(args.get(1).and_then(Arg::reference).unwrap_or(0))?
1077            }
1078            "BEZIER_CURVE" | "UNIFORM_CURVE" | "QUASI_UNIFORM_CURVE" => {
1079                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1080                let degree = args.get(1).and_then(Arg::number).unwrap_or(1.0) as usize;
1081                let control: Vec<Point> = args
1082                    .get(2)
1083                    .and_then(Arg::list)
1084                    .unwrap_or(&[])
1085                    .iter()
1086                    .filter_map(Arg::reference)
1087                    .map(|r| self.point(r))
1088                    .collect::<OgeomResult<_>>()?;
1089                let knots = implied_knots(&keyword, degree, control.len());
1090                Some(BSplineCurve::new(KnotVector::new(knots, degree)?, control, self.tol)?.into())
1091            }
1092            "HYPERBOLA" => {
1093                let frame = self.frame(args[1].reference().unwrap_or(0))?;
1094                let a = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
1095                let b = args.get(3).and_then(Arg::number).unwrap_or(0.0) * scale;
1096                // The branch's reach in its own parameter: cosh 20 carries a
1097                // hyperbola far past any part.
1098                Some(HyperbolaCurve::new(Hyperbola::new(frame, a, b, self.tol)?, 20.0)?.into())
1099            }
1100            "PARABOLA" => {
1101                let frame = self.frame(args[1].reference().unwrap_or(0))?;
1102                let focal = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
1103                Some(
1104                    ParabolaCurve::new(Parabola::new(frame, focal, self.tol)?, SURFACE_EXTENT)?
1105                        .into(),
1106                )
1107            }
1108            "TRIMMED_CURVE" => {
1109                // An edge's ends are its vertices; the trims restate them.
1110                self.curve(args.get(1).and_then(Arg::reference).unwrap_or(0))?
1111            }
1112            "OFFSET_CURVE_3D" => {
1113                let Some(basis) = self.curve(args.get(1).and_then(Arg::reference).unwrap_or(0))?
1114                else {
1115                    return Ok(None);
1116                };
1117                let distance = args.get(2).and_then(Arg::number).unwrap_or(0.0) * scale;
1118                let reference =
1119                    self.direction(args.get(4).and_then(Arg::reference).unwrap_or(0))?;
1120                Some(Curve::Offset(Box::new(ogeom_geom::OffsetCurve::new(
1121                    basis, distance, reference,
1122                )?)))
1123            }
1124            "POLYLINE" => {
1125                let points: Vec<Point> = args
1126                    .get(1)
1127                    .and_then(Arg::list)
1128                    .unwrap_or(&[])
1129                    .iter()
1130                    .filter_map(Arg::reference)
1131                    .map(|r| self.point(r))
1132                    .collect::<OgeomResult<_>>()?;
1133                if points.len() < 2 {
1134                    ogeom_bail!(Construction, "#{id}: a polyline of fewer than two points");
1135                }
1136                // Degree one through the points, a knot at each.
1137                let mut knots = vec![0.0];
1138                #[allow(clippy::cast_precision_loss)]
1139                knots.extend((0..points.len()).map(|i| i as f64));
1140                #[allow(clippy::cast_precision_loss)]
1141                knots.push((points.len() - 1) as f64);
1142                Some(BSplineCurve::new(KnotVector::new(knots, 1)?, points, self.tol)?.into())
1143            }
1144            "COMPOSITE_CURVE" => {
1145                // Its segments, each over its own trim and in its own
1146                // sense, as splines joined end to start.
1147                let mut joined: Option<(KnotVector, Vec<ogeom_math::Weighted<Point>>)> = None;
1148                let segments: Vec<u64> = args
1149                    .get(1)
1150                    .and_then(Arg::list)
1151                    .unwrap_or(&[])
1152                    .iter()
1153                    .filter_map(Arg::reference)
1154                    .collect();
1155                for segment in segments {
1156                    let sargs = self.args(segment, "COMPOSITE_CURVE_SEGMENT")?;
1157                    let same_sense = !sargs.get(1).is_some_and(|a| a.is_enum("F"));
1158                    let parent = sargs.get(2).and_then(Arg::reference).unwrap_or(0);
1159                    let Some((curve, range)) = self.bounded_curve(parent)? else {
1160                        return Ok(None);
1161                    };
1162                    let mut spline = curve.to_bspline_over(range, self.tol)?;
1163                    if !same_sense {
1164                        let (knots, control) =
1165                            ogeom_math::bspline::reverse(spline.knots(), spline.control_points());
1166                        spline = BSplineCurve::rational(knots, control)?;
1167                    }
1168                    joined = Some(match joined {
1169                        None => (spline.knots().clone(), spline.control_points().to_vec()),
1170                        Some(held) => join_splines(held, &spline, self.tol)?,
1171                    });
1172                }
1173                let Some((knots, control)) = joined else {
1174                    ogeom_bail!(Construction, "#{id}: a composite curve with no segments");
1175                };
1176                Some(BSplineCurve::rational(knots, control)?.into())
1177            }
1178            "CURVE_REPLICA" => {
1179                let Some(parent) = self.curve(args.get(1).and_then(Arg::reference).unwrap_or(0))?
1180                else {
1181                    return Ok(None);
1182                };
1183                let motion = self
1184                    .transformation_operator(args.get(2).and_then(Arg::reference).unwrap_or(0))?;
1185                Some(parent.transformed(&motion, self.tol)?)
1186            }
1187            other => {
1188                self.report.warnings.push(format!(
1189                    "#{id}: curve kind {other} is not read yet; its edge is skipped"
1190                ));
1191                None
1192            }
1193        };
1194        Ok(out)
1195    }
1196
1197    /// A rectangular composite surface: its grid of patches joined into one
1198    /// B-spline surface, so a face across the grid is a face on one surface
1199    /// and needs no rebuilding. Each patch in its spline form over its own
1200    /// bounds, turned where its sense says, raised to the grid's degrees,
1201    /// its knots unified with its column's and its row's, and joined to its
1202    /// neighbours on their shared boundary. Neighbours that do not meet
1203    /// there are refused by name.
1204    fn composite_surface(&mut self, id: u64, args: &[Arg]) -> OgeomResult<SurfaceGeometry> {
1205        let rows: Vec<Vec<u64>> = args
1206            .get(1)
1207            .and_then(Arg::list)
1208            .unwrap_or(&[])
1209            .iter()
1210            .map(|row| {
1211                row.list()
1212                    .unwrap_or(&[])
1213                    .iter()
1214                    .filter_map(Arg::reference)
1215                    .collect()
1216            })
1217            .collect();
1218        if rows.is_empty()
1219            || rows
1220                .iter()
1221                .any(|r| r.len() != rows[0].len() || r.is_empty())
1222        {
1223            ogeom_bail!(
1224                Construction,
1225                "#{id}: a composite surface's grid is not rectangular"
1226            );
1227        }
1228        let mut grid: Vec<Vec<Patch>> = Vec::with_capacity(rows.len());
1229        for row in &rows {
1230            let mut out = Vec::with_capacity(row.len());
1231            for &patch in row {
1232                let pargs = self.args(patch, "SURFACE_PATCH")?;
1233                let parent = pargs.get(1).and_then(Arg::reference).unwrap_or(0);
1234                let u_sense = !pargs.get(4).is_some_and(|a| a.is_enum("F"));
1235                let v_sense = !pargs.get(5).is_some_and(|a| a.is_enum("F"));
1236                let spline = self.bounded_surface_spline(parent)?;
1237                let mut p = Patch::of(&spline)?;
1238                if !u_sense {
1239                    p = p.reversed_u();
1240                }
1241                if !v_sense {
1242                    p = p.transposed().reversed_u().transposed();
1243                }
1244                out.push(p);
1245            }
1246            grid.push(out);
1247        }
1248        join_patches(grid, self.tol).map_err(|e| {
1249            ogeom_core::ogeom_err!(
1250                Construction,
1251                "#{id}: a composite surface's patches do not join: {e}"
1252            )
1253        })
1254    }
1255
1256    /// A composite's patch surface as a B-spline over its own bounds: a
1257    /// B-spline as it is; a rectangularly trimmed surface's basis over its
1258    /// window, in the file's reading of the basis's parameters.
1259    fn bounded_surface_spline(&mut self, id: u64) -> OgeomResult<ogeom_geom::BSplineSurface> {
1260        let keyword = self.instance(id)?.keyword().to_owned();
1261        if keyword == "RECTANGULAR_TRIMMED_SURFACE" {
1262            let args = self.args(id, "RECTANGULAR_TRIMMED_SURFACE")?;
1263            let basis_id = args.get(1).and_then(Arg::reference).unwrap_or(0);
1264            let Some(basis) = self.surface(basis_id)? else {
1265                ogeom_bail!(Construction, "#{id}: a patch's basis is not read");
1266            };
1267            let angular = matches!(
1268                self.instance(basis_id)?.keyword(),
1269                "CYLINDRICAL_SURFACE"
1270                    | "CONICAL_SURFACE"
1271                    | "SPHERICAL_SURFACE"
1272                    | "TOROIDAL_SURFACE"
1273            );
1274            let n = |i: usize| args.get(i).and_then(Arg::number).unwrap_or(0.0);
1275            let (u_scale, v_scale) = if angular {
1276                (self.angle_scale, self.report.scale_mm)
1277            } else {
1278                (self.report.scale_mm, self.report.scale_mm)
1279            };
1280            let (u1, u2) = (n(2) * u_scale, n(3) * u_scale);
1281            let (v1, v2) = (n(4) * v_scale, n(5) * v_scale);
1282            let window = ((u1.min(u2), u1.max(u2)), (v1.min(v2), v1.max(v2)));
1283            let trimmed = ogeom_geom::TrimmedSurface::new(basis, window.0, window.1, self.tol)?;
1284            return SurfaceGeometry::Trimmed(Box::new(trimmed)).to_bspline(self.tol);
1285        }
1286        let Some(surface) = self.surface(id)? else {
1287            ogeom_bail!(Construction, "#{id}: a patch's surface is not read");
1288        };
1289        surface.to_bspline(self.tol)
1290    }
1291
1292    /// A curve with the stretch of it an entity bounds: a trimmed curve's
1293    /// basis between its trims (a point where the file gives one, else its
1294    /// parameter in the file's own reading of the basis), in its own sense;
1295    /// any other curve over its own domain, which must be bounded.
1296    fn bounded_curve(&mut self, id: u64) -> OgeomResult<Option<(Curve, (f64, f64))>> {
1297        let keyword = self.instance(id)?.keyword().to_owned();
1298        if keyword != "TRIMMED_CURVE" {
1299            let Some(curve) = self.curve(id)? else {
1300                return Ok(None);
1301            };
1302            let (lo, hi) = curve.domain();
1303            if !lo.is_finite() || !hi.is_finite() || hi - lo > SURFACE_EXTENT {
1304                ogeom_bail!(
1305                    Construction,
1306                    "#{id}: an unbounded curve cannot stand as a composite's segment"
1307                );
1308            }
1309            return Ok(Some((curve, (lo, hi))));
1310        }
1311        let args = self.args(id, "TRIMMED_CURVE")?;
1312        let basis_id = args.get(1).and_then(Arg::reference).unwrap_or(0);
1313        let Some(basis) = self.curve(basis_id)? else {
1314            return Ok(None);
1315        };
1316        let basis_keyword = self.instance(basis_id)?.keyword().to_owned();
1317        let forward = !args.get(4).is_some_and(|a| a.is_enum("F"));
1318        let mut ends = Vec::with_capacity(2);
1319        for trim in [args.get(2), args.get(3)] {
1320            let items = trim.and_then(Arg::list).unwrap_or(&[]).to_vec();
1321            let mut at: Option<Point> = None;
1322            for item in &items {
1323                if let Some(r) = item.reference() {
1324                    at = Some(self.point(r)?);
1325                }
1326            }
1327            if at.is_none() {
1328                for item in &items {
1329                    if let Arg::Typed(name, values) = item
1330                        && name == "PARAMETER_VALUE"
1331                        && let Some(v) = values.first().and_then(Arg::number)
1332                    {
1333                        at =
1334                            Some(self.step_parameter_point(&basis, &basis_keyword, basis_id, v)?);
1335                    }
1336                }
1337            }
1338            let Some(point) = at else {
1339                ogeom_bail!(
1340                    Construction,
1341                    "#{id}: a trim names neither point nor parameter"
1342                );
1343            };
1344            let t = match self.parameter_of(&basis, point) {
1345                Some(t) => t,
1346                None => project_on_curve(&basis, point, 256, self.tol)?.parameter,
1347            };
1348            ends.push(t);
1349        }
1350        let (mut a, mut b) = (ends[0], ends[1]);
1351        if !forward {
1352            core::mem::swap(&mut a, &mut b);
1353        }
1354        if basis.is_periodic() && b <= a {
1355            let (lo, hi) = basis.domain();
1356            b += hi - lo;
1357        }
1358        let range = if a <= b { (a, b) } else { (b, a) };
1359        let mut curve = basis;
1360        if !forward || a > b {
1361            // Travelled backward: the same points, the parameter turned.
1362            let spline = curve.to_bspline_over(range, self.tol)?;
1363            let (knots, control) =
1364                ogeom_math::bspline::reverse(spline.knots(), spline.control_points());
1365            curve = BSplineCurve::rational(knots, control)?.into();
1366            let domain = curve.domain();
1367            return Ok(Some((curve, domain)));
1368        }
1369        Ok(Some((curve, range)))
1370    }
1371
1372    /// Where the file's parameter `v` stands on a basis curve, read as the
1373    /// file reads it: a line's by its own vector's length, a conic's as an
1374    /// angle in the file's angle unit, a spline's as it is.
1375    fn step_parameter_point(
1376        &mut self,
1377        basis: &Curve,
1378        keyword: &str,
1379        basis_id: u64,
1380        v: f64,
1381    ) -> OgeomResult<Point> {
1382        match keyword {
1383            "LINE" => {
1384                let args = self.args(basis_id, "LINE")?;
1385                let through = self.point(args[1].reference().unwrap_or(0))?;
1386                let vector = self.args(args[2].reference().unwrap_or(0), "VECTOR")?;
1387                let direction = self.direction(vector[1].reference().unwrap_or(0))?;
1388                let magnitude = vector.get(2).and_then(Arg::number).unwrap_or(1.0);
1389                Ok(through + direction.vector() * (v * magnitude * self.report.scale_mm))
1390            }
1391            "CIRCLE" | "ELLIPSE" => basis.point_at(v * self.angle_scale, self.tol),
1392            _ => basis.point_at(v, self.tol),
1393        }
1394    }
1395
1396    /// A Cartesian transformation operator as the motion it states: the
1397    /// axes it names (completed square to one another), its origin, and
1398    /// its uniform scale.
1399    fn transformation_operator(&mut self, id: u64) -> OgeomResult<Transform> {
1400        let args = self.args(id, "CARTESIAN_TRANSFORMATION_OPERATOR_3D")?;
1401        let direction_at = |this: &mut Self, i: usize| -> OgeomResult<Option<Vector>> {
1402            match args.get(i).and_then(Arg::reference) {
1403                Some(r) => Ok(Some(this.direction(r)?.vector())),
1404                None => Ok(None),
1405            }
1406        };
1407        let (axis1, axis2, axis3) = (
1408            direction_at(self, 1)?,
1409            direction_at(self, 2)?,
1410            direction_at(self, 5)?,
1411        );
1412        let origin = self.point(args.get(3).and_then(Arg::reference).unwrap_or(0))?;
1413        let scale = args.get(4).and_then(Arg::number).unwrap_or(1.0);
1414        let z = match (axis3, axis1, axis2) {
1415            (Some(z), _, _) => z,
1416            (None, Some(x), Some(y)) => x.cross(y),
1417            _ => Vector::Z,
1418        };
1419        let z = Direction::new(z, self.tol)?.vector();
1420        let x = match axis1 {
1421            Some(x) => x - z * x.dot(z),
1422            None => {
1423                let seed = if z.x.abs() < 0.9 {
1424                    Vector::X
1425                } else {
1426                    Vector::Y
1427                };
1428                seed - z * seed.dot(z)
1429            }
1430        };
1431        let x = Direction::new(x, self.tol)?.vector();
1432        let y = z.cross(x);
1433        let linear = Matrix3::new([[x.x, y.x, z.x], [x.y, y.y, z.y], [x.z, y.z, z.z]]);
1434        Transform::from_parts(linear, scale, origin.to_vector(), self.tol.angular())
1435    }
1436
1437    /// The parameter of a point on one of this kernel's curves.
1438    fn parameter_of(&self, curve: &Curve, p: Point) -> Option<f64> {
1439        crate::inversion::parameter_on(curve, p)
1440    }
1441
1442    // --- topology ------------------------------------------------------------
1443
1444    fn vertex(&mut self, id: u64) -> OgeomResult<Shape> {
1445        if let Some(shape) = self.vertices.get(&id) {
1446            return Ok(shape.clone());
1447        }
1448        let args = self.args(id, "VERTEX_POINT")?;
1449        let point = self.point(args[1].reference().unwrap_or(0))?;
1450        let shape = make_vertex(&mut self.model, point).shape;
1451        self.vertices.insert(id, shape.clone());
1452        Ok(shape)
1453    }
1454
1455    /// An `EDGE_CURVE`, built once, curve-forward.
1456    ///
1457    /// Returns the edge, its curve, its range, and whether STEP's edge
1458    /// direction runs *against* the curve, which each use folds into its
1459    /// own orientation.
1460    fn edge(&mut self, id: u64) -> OgeomResult<Option<BuiltEdge>> {
1461        if let Some(found) = self.edges.get(&id) {
1462            return Ok(Some(found.clone()));
1463        }
1464        let args = self.args(id, "EDGE_CURVE")?;
1465        let v1 = args[1].reference().unwrap_or(0);
1466        let v2 = args[2].reference().unwrap_or(0);
1467        let Some(mut curve) = self.curve(args[3].reference().unwrap_or(0))? else {
1468            return Ok(None);
1469        };
1470        let same_sense = !args.get(4).is_some_and(|a| a.is_enum("F"));
1471
1472        let p1 = {
1473            let vargs = self.args(v1, "VERTEX_POINT")?;
1474            self.point(vargs[1].reference().unwrap_or(0))?
1475        };
1476        let p2 = {
1477            let vargs = self.args(v2, "VERTEX_POINT")?;
1478            self.point(vargs[1].reference().unwrap_or(0))?
1479        };
1480
1481        // The edge is built along the curve's own parameter; a STEP edge
1482        // running the other way is flagged, and every use composes the flag
1483        // into its orientation.
1484        let (start, end, mut flipped) = if same_sense {
1485            (p1, p2, false)
1486        } else {
1487            (p2, p1, true)
1488        };
1489        let (range, closed) = match (
1490            self.parameter_of(&curve, start),
1491            self.parameter_of(&curve, end),
1492        ) {
1493            (Some(a), Some(b)) => {
1494                let period = if curve.is_periodic() {
1495                    let (lo, hi) = curve.domain();
1496                    hi - lo
1497                } else {
1498                    0.0
1499                };
1500                if v1 == v2 {
1501                    ((a, a + if period > 0.0 { period } else { 0.0 }), true)
1502                } else if period > 0.0 && b <= a + self.tol.parametric() {
1503                    ((a, b + period), false)
1504                } else if b < a - self.tol.parametric() {
1505                    // The vertices stand at descending parameters on an
1506                    // open curve whatever the sense flag says: a
1507                    // mesh-to-STEP converter writes every edge forward and
1508                    // lets the line run the other way. The edge is built
1509                    // along the curve's own parameter and runs against it.
1510                    flipped = !flipped;
1511                    self.report.warnings.push(format!(
1512                        "#{id}: the edge's vertices run against its curve's \
1513                         parameter despite its sense flag; the edge was reversed"
1514                    ));
1515                    self.tally("edge-against-curve", a - b, id);
1516                    ((b, a), false)
1517                } else {
1518                    ((a, b), false)
1519                }
1520            }
1521            _ => {
1522                // No closed-form inversion: take the curve's own domain and
1523                // hold the endpoints to it.
1524                //
1525                // A closed edge whose one vertex sits on the curve but away
1526                // from its seam (a fitted loop written with its start
1527                // wherever the fit began, the vertex millimetres along it)
1528                // would otherwise be held to a seam the vertex misses by
1529                // that much, and the vertex's tolerance widened to say so.
1530                // The seam is moved to the vertex instead: the same curve,
1531                // begun where the edge does.
1532                if v1 == v2
1533                    && let Curve::BSpline(spline) = &curve
1534                {
1535                    let (lo, hi) = curve.domain();
1536                    let gap = curve.point_at(lo, self.tol)?.distance(start);
1537                    if gap > self.tol.confusion() * 10.0 {
1538                        let samples = (spline.control_points().len() * 8).max(64);
1539                        if let Ok(found) = project_on_curve(&curve, start, samples, self.tol)
1540                            && found.distance <= self.tol.confusion() * 1e4
1541                            && found.parameter > lo + self.tol.parametric()
1542                            && found.parameter < hi - self.tol.parametric()
1543                            && let Ok(moved) = spline.reseamed_at(found.parameter, self.tol)
1544                        {
1545                            curve = Curve::BSpline(moved);
1546                            self.report.warnings.push(format!(
1547                                "#{id}: a closed edge's vertex sits {gap:.2e} along its \
1548                                 curve from the seam; the seam was moved to the vertex"
1549                            ));
1550                            self.tally("reseamed", gap, id);
1551                        }
1552                    }
1553                }
1554                let (lo, hi) = curve.domain();
1555                let head = curve.point_at(lo, self.tol)?;
1556                let tail = curve.point_at(hi, self.tol)?;
1557                let (head_miss, tail_miss) = (head.distance(start), tail.distance(end));
1558                if head_miss > self.tol.confusion() * 10.0
1559                    || tail_miss > self.tol.confusion() * 10.0
1560                {
1561                    // An open edge whose vertices stand *on* the curve but
1562                    // short of its ends (a file that writes the whole
1563                    // spline and lets the vertices say where the edge
1564                    // stops, millimetres in) takes the window between the
1565                    // vertices' own feet. Held to the whole curve, the edge
1566                    // overshoots its neighbours and the face it bounds draws
1567                    // as nothing at all.
1568                    let window = if v1 == v2 {
1569                        None
1570                    } else {
1571                        window_between_feet(&curve, start, end, self.tol)
1572                    };
1573                    if let Some(window) = window {
1574                        self.report.warnings.push(format!(
1575                            "#{id}: edge endpoints sit {head_miss:.2e} and {tail_miss:.2e} \
1576                             from its curve's ends; the window between the vertices' \
1577                             feet on the curve was taken"
1578                        ));
1579                        self.tally("vertex-window", head_miss.max(tail_miss), id);
1580                        (window, false)
1581                    } else {
1582                        self.report.warnings.push(format!(
1583                            "#{id}: edge endpoints sit {head_miss:.2e} and {tail_miss:.2e} \
1584                             from its curve's ends; the curve's own domain was taken"
1585                        ));
1586                        ((lo, hi), v1 == v2)
1587                    }
1588                } else {
1589                    ((lo, hi), v1 == v2)
1590                }
1591            }
1592        };
1593        let _ = closed;
1594
1595        let (vlo, vhi) = if flipped {
1596            (self.vertex(v2)?, self.vertex(v1)?)
1597        } else {
1598            (self.vertex(v1)?, self.vertex(v2)?)
1599        };
1600        // Real files are imprecise, and NIST's own readme says so of these.
1601        // Where the curve's end misses its vertex by more than the default
1602        // tolerance, the vertex's tolerance *grows* to state the gap; the
1603        // data model's growing tolerances are exactly for this, and the
1604        // warning keeps the healing visible.
1605        for (vertex, t) in [(&vlo, range.0), (&vhi, range.1)] {
1606            let end = curve.point_at(t, self.tol)?;
1607            let stated = {
1608                let Some(node) = self.model.node(vertex) else {
1609                    continue;
1610                };
1611                let Some(data) = node.data().as_vertex() else {
1612                    continue;
1613                };
1614                (data.point, data.tolerance)
1615            };
1616            let gap = end.distance(stated.0);
1617            if gap > stated.1.get() {
1618                if let Some(node) = self.model.node_mut(vertex)
1619                    && let ogeom_topo::NodeData::Vertex(data) = node.data_mut()
1620                {
1621                    data.tolerance = data.tolerance.widen_to(gap + self.tol.confusion());
1622                }
1623                self.warn_vertex_miss(id, gap);
1624            }
1625        }
1626        let shape =
1627            make_edge_between(&mut self.model, curve.clone(), range, &vlo, &vhi, self.tol)?.shape;
1628        let entry = (shape, curve, range, flipped);
1629        self.edges.insert(id, entry.clone());
1630        Ok(Some(entry))
1631    }
1632
1633    fn face(&mut self, id: u64) -> OgeomResult<Option<Shape>> {
1634        if let Some(shape) = self.faces.get(&id) {
1635            return Ok(Some(shape.clone()));
1636        }
1637        let args = self.face_args(id)?;
1638        let bounds: Vec<u64> = args
1639            .get(1)
1640            .and_then(Arg::list)
1641            .unwrap_or(&[])
1642            .iter()
1643            .filter_map(Arg::reference)
1644            .collect();
1645        let Some(surface) = self.surface(args[2].reference().unwrap_or(0))? else {
1646            return Ok(None);
1647        };
1648        let face_forward = !args.get(3).is_some_and(|a| a.is_enum("F"));
1649        let surface_id = self.model.geometry_mut().add_surface(surface.clone());
1650
1651        // Outer bound first, so the face's first wire is its outer ring.
1652        let mut ordered = bounds.clone();
1653        ordered.sort_by_key(|b| {
1654            self.exchange
1655                .data
1656                .get(b)
1657                .map_or(1, |i| i32::from(i.part("FACE_OUTER_BOUND").is_none()))
1658        });
1659
1660        // Which edges this face uses twice: those are seams, and get both
1661        // sides' pcurves.
1662        let mut edge_uses: HashMap<u64, usize> = HashMap::new();
1663        for &bound in &ordered {
1664            let bargs = self.bound_args(bound)?;
1665            if self.instance(bargs.0)?.part("VERTEX_LOOP").is_some() {
1666                continue;
1667            }
1668            let loop_args = self.args(bargs.0, "EDGE_LOOP")?;
1669            for oe in loop_args.get(1).and_then(Arg::list).unwrap_or(&[]) {
1670                if let Some(oe_id) = oe.reference() {
1671                    let oargs = self.args(oe_id, "ORIENTED_EDGE")?;
1672                    if let Some(e) = oargs.get(3).and_then(Arg::reference) {
1673                        *edge_uses.entry(e).or_default() += 1;
1674                    }
1675                }
1676            }
1677        }
1678
1679        let mut wires = Vec::new();
1680        let mut annotated: HashSet<u64> = HashSet::new();
1681        for &bound in &ordered {
1682            let (loop_id, bound_forward) = self.bound_args(bound)?;
1683            if let Some(vertex_loop) = {
1684                let instance = self.instance(loop_id)?;
1685                instance.part("VERTEX_LOOP").map(<[Arg]>::to_vec)
1686            } {
1687                // A loop of one vertex: a pole or an apex. It has no edges,
1688                // but it still bounds the face in parameter space, as a
1689                // degenerate edge running across the chart at the row the
1690                // point collapses to, exactly as native cones and spheres
1691                // are built.
1692                let vertex_id = vertex_loop.get(1).and_then(Arg::reference).unwrap_or(0);
1693                let vargs = self.args(vertex_id, "VERTEX_POINT")?;
1694                let at = self.point(vargs[1].reference().unwrap_or(0))?;
1695                let vertex = self.vertex(vertex_id)?;
1696                let projection = ogeom_algo::project_on_surface(&surface, at, 32, self.tol)?;
1697                let ((ua, ub), _) = surface.domain();
1698                let row = projection.parameters.1;
1699                let mut data = ogeom_topo::EdgeData::new();
1700                data.degenerate = true;
1701                let edge = self
1702                    .model
1703                    .add_edge(data, &[vertex.clone(), vertex.clone()])?;
1704                let pcurve: PlanarCurve = ogeom_geom::Line2d::segment(
1705                    ogeom_math::Point2::new(ua, row),
1706                    ogeom_math::Point2::new(ub, row),
1707                    self.tol,
1708                )?
1709                .into();
1710                ogeom_algo::attach_pcurve(
1711                    &mut self.model,
1712                    &edge,
1713                    pcurve,
1714                    surface_id,
1715                    Location::identity(),
1716                    (0.0, ub - ua),
1717                )?;
1718                wires.push(make_wire(&mut self.model, &[edge], self.tol)?.shape);
1719                continue;
1720            }
1721            let loop_args = self.args(loop_id, "EDGE_LOOP")?;
1722            let mut uses: Vec<BoundUse> = Vec::new();
1723            for oe in loop_args.get(1).and_then(Arg::list).unwrap_or(&[]) {
1724                let Some(oe_id) = oe.reference() else {
1725                    continue;
1726                };
1727                let oargs = self.args(oe_id, "ORIENTED_EDGE")?;
1728                let Some(edge_id) = oargs.get(3).and_then(Arg::reference) else {
1729                    continue;
1730                };
1731                let forward = !oargs.get(4).is_some_and(|a| a.is_enum("F"));
1732                let Some((shape, curve, range, flipped)) = self.edge(edge_id)? else {
1733                    self.report.warnings.push(format!(
1734                        "#{id}: a bound references unreadable edge #{edge_id}; \
1735                         the face is skipped"
1736                    ));
1737                    return Ok(None);
1738                };
1739                // The use's direction composes the loop's, the bound's and
1740                // the edge-against-curve flag.
1741                let mut use_forward = forward == bound_forward;
1742                if flipped {
1743                    use_forward = !use_forward;
1744                }
1745                let placed = if use_forward {
1746                    shape.clone()
1747                } else {
1748                    shape.reversed()
1749                };
1750                uses.push((placed, shape, edge_id, curve, range));
1751            }
1752            if !bound_forward {
1753                uses.reverse();
1754            }
1755            // The window may have grown to hold this bound, and the images
1756            // are derived on the surface as it stands.
1757            self.widen_window(surface_id, &uses)?;
1758            let widened = self
1759                .model
1760                .geometry()
1761                .surface(surface_id)
1762                .cloned()
1763                .unwrap_or_else(|| surface.clone());
1764            self.chart_bound(id, &uses, &widened, surface_id, &edge_uses, &mut annotated)?;
1765            let edges: Vec<Shape> = uses.into_iter().map(|(placed, ..)| placed).collect();
1766            if edges.is_empty() {
1767                continue;
1768            }
1769            wires.push(make_wire(&mut self.model, &edges, self.tol)?.shape);
1770        }
1771        if wires.is_empty() {
1772            self.report
1773                .warnings
1774                .push(format!("#{id}: a face with no readable bounds is skipped"));
1775            return Ok(None);
1776        }
1777        // As the bounds left it: a window stretched to hold them is the
1778        // surface every path below builds on, including the band, which
1779        // registers the value it is handed rather than the one on record.
1780        let surface = self
1781            .model
1782            .geometry()
1783            .surface(surface_id)
1784            .cloned()
1785            .unwrap_or(surface);
1786
1787        // A periodic face bound only by closed rings (a cylinder band
1788        // between two circles) arrives without a seam edge, which is a
1789        // legitimate STEP shape and an open rectangle in this kernel's
1790        // chart. The seam is synthesised the way native cylinders build it:
1791        // one edge at the period join, appearing in the wire twice.
1792        if wires.len() == 2
1793            && surface.is_periodic_u()
1794            && let [(e_lo, _v_lo), (e_hi, _v_hi)] =
1795                closed_ring_edges(&self.model, &wires)?.as_slice()
1796            // Only rings that are parallels of the surface make a band.
1797            // Two closed circles that merely lie on it (a button head's
1798            // rims, square to the screw on a sphere whose chart runs along
1799            // z) bound a legitimate face on their own, nested loops in the
1800            // chart, and take the ordinary path below without a word.
1801            && ogeom_algo::rings_are_parallels(&self.model, &surface, &[e_lo, e_hi], self.tol)?
1802        {
1803            {
1804                // The band construction is ogeom-algo's make_revolution_band:
1805                // one authority shared with the healer. Anything it refuses
1806                // (a spline ring, ring vertices at different angles, a
1807                // surface with no closed-form iso-curve) becomes a warning
1808                // and the raw bounds, not an error.
1809                match ogeom_algo::make_revolution_band(
1810                    &mut self.model,
1811                    &surface,
1812                    e_lo,
1813                    e_hi,
1814                    self.tol,
1815                ) {
1816                    Ok(built) => {
1817                        let shape = if face_forward {
1818                            built
1819                        } else {
1820                            built.reversed()
1821                        };
1822                        self.faces.insert(id, shape.clone());
1823                        return Ok(Some(shape));
1824                    }
1825                    Err(e) => {
1826                        self.report.warnings.push(format!(
1827                            "#{id}: no seam could be synthesised ({e}); the \
1828                             face may not triangulate"
1829                        ));
1830                    }
1831                }
1832            }
1833        }
1834
1835        // A cone face bounded by a *single* closed ring: the apex is the
1836        // other boundary, and some files simply never write it: no vertex
1837        // loop, nothing. The geometry leaves one choice of what the face
1838        // means, so the apex is synthesised and the band built as if the
1839        // file had said so.
1840        if wires.len() == 1
1841            && matches!(surface, SurfaceGeometry::Cone(_))
1842            && let [(ring, _)] = closed_ring_edges(&self.model, &wires)?.as_slice()
1843        {
1844            match ogeom_algo::make_apex_band(&mut self.model, &surface, ring, self.tol) {
1845                Ok(built) => {
1846                    let shape = if face_forward {
1847                        built
1848                    } else {
1849                        built.reversed()
1850                    };
1851                    self.faces.insert(id, shape.clone());
1852                    return Ok(Some(shape));
1853                }
1854                Err(e) => {
1855                    self.report.warnings.push(format!(
1856                        "#{id}: no apex could be synthesised ({e}); the face \
1857                         may not triangulate"
1858                    ));
1859                }
1860            }
1861        }
1862
1863        // Every wire's images on one branch of a periodic chart: a file's
1864        // pcurves, or fits of them, answer in whatever phase they like, and
1865        // a hole loop straddling a drum's seam arrives half on each branch.
1866        ogeom_algo::chain_wire_branches(&mut self.model, surface_id, &wires, self.tol)?;
1867        let built = make_face_on(&mut self.model, surface_id, &wires, self.tol)?.shape;
1868        let shape = if face_forward {
1869            built
1870        } else {
1871            built.reversed()
1872        };
1873        self.faces.insert(id, shape.clone());
1874        Ok(Some(shape))
1875    }
1876
1877    /// A bound's loop and orientation, whichever of the two bound kinds it is.
1878    fn bound_args(&mut self, id: u64) -> OgeomResult<(u64, bool)> {
1879        let instance = self.instance(id)?;
1880        let args = instance
1881            .part("FACE_OUTER_BOUND")
1882            .or_else(|| instance.part("FACE_BOUND"))
1883            .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "#{id} is not a face bound"))?
1884            .to_vec();
1885        let loop_id = args.get(1).and_then(Arg::reference).unwrap_or(0);
1886        let forward = !args.get(2).is_some_and(|a| a.is_enum("F"));
1887        Ok((loop_id, forward))
1888    }
1889
1890    /// Attach this face's pcurve (or both seam sides) to an edge.
1891    #[allow(clippy::too_many_arguments)]
1892    /// One use a bound makes of an edge: the edge as the loop walks it, the
1893    /// edge as it was built, the file's id for it, and its curve and range.
1894    /// Stretch a surface's parameter window to hold the edges that bound it.
1895    ///
1896    /// The window a reader gives a plane, a cylinder or a cone is a
1897    /// convention: those surfaces are unbounded, and [`SURFACE_EXTENT`] is
1898    /// a guess at how far past its own geometry a file will reach. A file
1899    /// can falsify the guess: a real assembly places a cylinder's own
1900    /// origin half a kilometre from the part it belongs to, so the trim's
1901    /// height parameter runs to −5e5 where the window stopped at −1e5,
1902    /// and then the surface refuses to be evaluated where its own face
1903    /// lies, and the face draws as a hole.
1904    ///
1905    /// So the window is measured rather than guessed: the edges' own
1906    /// points, projected onto the surface's chart by the geometry that
1907    /// defines it, with the guess kept as a floor. Widening is safe for a
1908    /// surface other faces share, since a window only ever grows.
1909    fn widen_window(
1910        &mut self,
1911        surface_id: ogeom_topo::SurfaceId,
1912        uses: &[BoundUse],
1913    ) -> OgeomResult<()> {
1914        use ogeom_geom::Curve3d as _;
1915        let Some(surface) = self.model.geometry().surface(surface_id).cloned() else {
1916            return Ok(());
1917        };
1918        // Where each surface keeps its unbounded directions, and how a point
1919        // of space reads on them.
1920        let along: Box<ChartReading> = match &surface {
1921            SurfaceGeometry::Plane(p) => {
1922                let frame = p.plane().frame();
1923                let (origin, x, y) = (frame.origin(), frame.x().vector(), frame.y().vector());
1924                Box::new(move |at: Point| (Some((at - origin).dot(x)), Some((at - origin).dot(y))))
1925            }
1926            SurfaceGeometry::Cylinder(c) => {
1927                let axis = c.cylinder().axis();
1928                let (origin, direction) = (axis.location, axis.direction.vector());
1929                Box::new(move |at: Point| (None, Some((at - origin).dot(direction))))
1930            }
1931            SurfaceGeometry::Cone(c) => {
1932                let axis = c.cone().axis();
1933                let (origin, direction) = (axis.location, axis.direction.vector());
1934                Box::new(move |at: Point| (None, Some((at - origin).dot(direction))))
1935            }
1936            // A swept curve: a point's sweep parameter is its reach along
1937            // the direction less the swept curve's own, read against the
1938            // middle of the curve's reach; the curve's half-reach either
1939            // way is inside the margin the window grows by.
1940            SurfaceGeometry::Extrusion(e) => {
1941                let direction = e.direction().vector();
1942                let (lo, hi) = e.curve().domain();
1943                let (mut least, mut most) = (f64::INFINITY, f64::NEG_INFINITY);
1944                for k in 0..=32 {
1945                    let t = lo + (hi - lo) * f64::from(k) / 32.0;
1946                    if let Ok(p) = e.curve().point_at(t, self.tol) {
1947                        let reach = p.to_vector().dot(direction);
1948                        least = least.min(reach);
1949                        most = most.max(reach);
1950                    }
1951                }
1952                let middle = f64::midpoint(least, most);
1953                Box::new(move |at: Point| (None, Some(at.to_vector().dot(direction) - middle)))
1954            }
1955            _ => return Ok(()),
1956        };
1957        const STATIONS: usize = 4;
1958        let (mut ua, mut ub) = (-SURFACE_EXTENT, SURFACE_EXTENT);
1959        let (mut va, mut vb) = (-SURFACE_EXTENT, SURFACE_EXTENT);
1960        for (_, _, _, curve, range) in uses {
1961            for step in 0..=STATIONS {
1962                #[allow(clippy::cast_precision_loss)]
1963                let t = range.0 + (range.1 - range.0) * (step as f64 / STATIONS as f64);
1964                let Ok(at) = curve.point_at(t, self.tol) else {
1965                    continue;
1966                };
1967                let (u, v) = along(at);
1968                if let Some(u) = u {
1969                    ua = ua.min(u);
1970                    ub = ub.max(u);
1971                }
1972                if let Some(v) = v {
1973                    va = va.min(v);
1974                    vb = vb.max(v);
1975                }
1976            }
1977        }
1978        let ((was_u, was_v), grown) = (surface.domain(), (ub - ua) * 0.05);
1979        if ua >= was_u.0 && ub <= was_u.1 && va >= was_v.0 && vb <= was_v.1 {
1980            return Ok(());
1981        }
1982        let (u, v) = (
1983            (ua.min(was_u.0) - grown, ub.max(was_u.1) + grown),
1984            (va.min(was_v.0) - grown, vb.max(was_v.1) + grown),
1985        );
1986        let wider = match surface {
1987            SurfaceGeometry::Plane(p) => PlaneSurface::over(p.plane(), u, v)?.into(),
1988            SurfaceGeometry::Cylinder(c) => CylinderSurface::new(c.cylinder(), v)?.into(),
1989            SurfaceGeometry::Cone(c) => ConeSurface::new(c.cone(), v)?.into(),
1990            SurfaceGeometry::Extrusion(e) => {
1991                ExtrusionSurface::over(e.curve().clone(), e.direction(), v)?.into()
1992            }
1993            other => other,
1994        };
1995        if let Some(held) = self.model.geometry_mut().surface_mut(surface_id) {
1996            *held = wider;
1997        }
1998        Ok(())
1999    }
2000
2001    /// One bound's images, chained around the face's chart.
2002    ///
2003    /// Each is derived on its own (the exact projection where the pair has
2004    /// a closed form, the fitted one where it does not), and a periodic
2005    /// chart then leaves a branch to choose. Chosen edge by edge the choice
2006    /// is arbitrary and the wire comes apart: a drilled block's bore wall
2007    /// reads back with one rim two whole turns from the other, both the
2008    /// right circle and neither meeting the seam the wire closes on. So
2009    /// each image after the first is slid by whole periods until its start
2010    /// meets where the last one ended.
2011    ///
2012    /// A seam falls out of the same walk. The wire uses it twice, up one
2013    /// column of the chart and down the other, and those columns are one
2014    /// image a period apart, so the walk finds both, and the use that runs
2015    /// forward is the forward side. Where it cannot, because the chart
2016    /// closes without being periodic, the other column goes a chart's width
2017    /// over as it always did.
2018    fn chart_bound(
2019        &mut self,
2020        face_id: u64,
2021        uses: &[BoundUse],
2022        surface: &SurfaceGeometry,
2023        surface_id: ogeom_topo::SurfaceId,
2024        edge_uses: &HashMap<u64, usize>,
2025        annotated: &mut HashSet<u64>,
2026    ) -> OgeomResult<()> {
2027        use ogeom_geom::Curve2d as _;
2028        let mut images: HashMap<u64, PlanarCurve> = HashMap::new();
2029        let mut sides: HashMap<u64, (Option<PlanarCurve>, Option<PlanarCurve>)> = HashMap::new();
2030        let mut order: Vec<u64> = Vec::new();
2031        let mut previous: Option<ogeom_math::Point2> = None;
2032        for (placed, shape, edge_id, curve, range) in uses {
2033            if !images.contains_key(edge_id) {
2034                let Some(image) =
2035                    self.image_for(face_id, *edge_id, shape, curve, *range, surface)?
2036                else {
2037                    continue;
2038                };
2039                images.insert(*edge_id, image);
2040                order.push(*edge_id);
2041            }
2042            let Some(image) = images.get(edge_id).cloned() else {
2043                continue;
2044            };
2045            let backwards = placed.orientation() == ogeom_topo::Orientation::Reversed;
2046            let (start, end) = if backwards {
2047                (range.1, range.0)
2048            } else {
2049                (range.0, range.1)
2050            };
2051            let image =
2052                crate::pcurves::shifted_to_meet(&image, start, previous, surface, self.tol)?;
2053            previous = Some(image.point_at(end, self.tol)?);
2054            let walked = sides.entry(*edge_id).or_default();
2055            if backwards {
2056                walked.1 = Some(image);
2057            } else {
2058                walked.0 = Some(image);
2059            }
2060        }
2061        for edge_id in order {
2062            if !annotated.insert(edge_id) {
2063                continue;
2064            }
2065            let Some((shape, range)) = uses
2066                .iter()
2067                .find(|(_, _, id, _, _)| *id == edge_id)
2068                .map(|(_, shape, _, _, range)| (shape.clone(), *range))
2069            else {
2070                continue;
2071            };
2072            let Some(image) = images.get(&edge_id).cloned() else {
2073                continue;
2074            };
2075            let walked = sides.remove(&edge_id).unwrap_or((None, None));
2076            let seam = edge_uses.get(&edge_id).copied().unwrap_or(1) > 1;
2077            let columns = match &walked {
2078                (Some(forward), Some(reversed)) => {
2079                    let at = forward.point_at(range.0, self.tol)?;
2080                    let other = reversed.point_at(range.0, self.tol)?;
2081                    (at.distance(other) > self.tol.confusion())
2082                        .then(|| (forward.clone(), reversed.clone()))
2083                }
2084                _ => None,
2085            };
2086            match (seam, columns) {
2087                (true, Some((forward, reversed))) => {
2088                    self.record_columns(&shape, forward, reversed, surface_id, range)?;
2089                }
2090                (true, None) => {
2091                    self.record_pcurve(&shape, image, surface_id, true, range)?;
2092                }
2093                (false, _) => {
2094                    let (forward, reversed) = walked;
2095                    let placed = forward.or(reversed).unwrap_or(image);
2096                    self.record_pcurve(&shape, placed, surface_id, false, range)?;
2097                }
2098            }
2099        }
2100        Ok(())
2101    }
2102
2103    fn image_for(
2104        &mut self,
2105        face_id: u64,
2106        edge_id: u64,
2107        edge: &Shape,
2108        curve: &Curve,
2109        range: (f64, f64),
2110        surface: &SurfaceGeometry,
2111    ) -> OgeomResult<Option<PlanarCurve>> {
2112        let widen = |p: PlanarCurve| -> PlanarCurve {
2113            // A line pcurve evaluates anywhere; its stated domain must still
2114            // cover the edge's range, which for a wrapped circle runs past
2115            // one period.
2116            if let PlanarCurve::Line(l) = &p {
2117                let (lo, hi) = (l.domain().0.min(range.0), l.domain().1.max(range.1));
2118                if let Ok(wider) = ogeom_geom::Line2d::over(l.axis(), lo, hi) {
2119                    return wider.into();
2120                }
2121            }
2122            p
2123        };
2124        // Claimed rather than derived, where the pass at the head of the solid
2125        // already did it. The fallbacks below stay exactly as they were, for
2126        // the faces no pass covered: a face reached outside a solid walk, or
2127        // one whose preparation refused.
2128        if let Some(prepared) = self.pcurves.remove(&(face_id, edge_id)) {
2129            match prepared {
2130                PreparedPcurve::Exact(exact) => {
2131                    return Ok(Some(widen(exact)));
2132                }
2133                PreparedPcurve::Fitted {
2134                    curve: fitted,
2135                    error,
2136                    met,
2137                    worst_off,
2138                    warning,
2139                } => {
2140                    if let Some(w) = warning {
2141                        self.warn_slop(w, worst_off, face_id);
2142                    }
2143                    if !met {
2144                        self.warn_fit_short(face_id, error);
2145                    }
2146                    if worst_off > self.tol.confusion()
2147                        && let Some(node) = self.model.node_mut(edge)
2148                        && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
2149                    {
2150                        data.tolerance = data.tolerance.widen_to(worst_off + self.tol.confusion());
2151                    }
2152                    return Ok(Some(fitted));
2153                }
2154                PreparedPcurve::Refused(why) => {
2155                    self.report.warnings.push(why);
2156                    self.note_untrimmed(face_id);
2157                    return Ok(None);
2158                }
2159            }
2160        }
2161        let pcurve =
2162            match ogeom_intersect::exact_pcurve_over(curve, range, surface, self.tol).map(widen) {
2163                Some(exact) => exact,
2164                None => {
2165                    // No closed form: a spline surface, or a combination the
2166                    // projection table lacks. The pcurve is *fitted at the
2167                    // curve's own parameters*: sample the edge, project each
2168                    // sample into the chart, fit the trace with the parameters
2169                    // held fixed, so same-parameter is preserved by construction
2170                    // and the reported error is the true chart deviation.
2171                    match crate::pcurves::fit_projected_pcurve(curve, range, surface, self.tol) {
2172                        Ok((fitted, error, met, worst_off, slop_warning)) => {
2173                            if let Some(w) = slop_warning {
2174                                self.warn_slop(w, worst_off, face_id);
2175                            }
2176                            if !met {
2177                                self.warn_fit_short(face_id, error);
2178                            }
2179                            // The edge provably sits `worst_off` from the surface
2180                            // it bounds; its tolerance grows to cover that, the
2181                            // same honesty the vertex ends get.
2182                            if worst_off > self.tol.confusion()
2183                                && let Some(node) = self.model.node_mut(edge)
2184                                && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
2185                            {
2186                                data.tolerance =
2187                                    data.tolerance.widen_to(worst_off + self.tol.confusion());
2188                            }
2189                            fitted
2190                        }
2191                        Err(e) => {
2192                            self.report.warnings.push(format!(
2193                                "face #{face_id}: no pcurve for an edge on this \
2194                             surface ({e}); the face may not triangulate"
2195                            ));
2196                            self.note_untrimmed(face_id);
2197                            return Ok(None);
2198                        }
2199                    }
2200                }
2201            };
2202        Ok(Some(pcurve))
2203    }
2204
2205    /// Count one occurrence of a warning kind toward the summary.
2206    fn tally(&mut self, kind: &'static str, measured: f64, exemplar: u64) {
2207        let entry = self.tallies.entry(kind).or_insert((0, 0.0, exemplar));
2208        entry.0 += 1;
2209        if measured > entry.1 {
2210            entry.1 = measured;
2211            entry.2 = exemplar;
2212        }
2213    }
2214
2215    /// A curve end missing its vertex: the prose, and the count.
2216    fn warn_vertex_miss(&mut self, id: u64, gap: f64) {
2217        self.report.warnings.push(format!(
2218            "#{id}: a curve end misses its vertex by {gap:.2e}; the vertex \
2219             tolerance grew to say so"
2220        ));
2221        self.tally("vertex-miss", gap, id);
2222    }
2223
2224    /// A fitted trim that stopped short of its target: prose and count.
2225    fn warn_fit_short(&mut self, face_id: u64, error: f64) {
2226        self.report.warnings.push(format!(
2227            "face #{face_id}: a projected pcurve fit stopped at {error:.2e}; \
2228             the face's mesh may sit that far off along this edge"
2229        ));
2230        self.tally("fit-short", error, face_id);
2231    }
2232
2233    /// The file's own boundary slop, carried and counted.
2234    fn warn_slop(&mut self, prose: String, worst: f64, exemplar: u64) {
2235        self.report.warnings.push(prose);
2236        self.tally("boundary-slop", worst, exemplar);
2237    }
2238
2239    /// Note a face left without a complete trim, once.
2240    fn note_untrimmed(&mut self, face_id: u64) {
2241        if self.untrimmed_ids.last() != Some(&face_id) {
2242            self.untrimmed_ids.push(face_id);
2243            self.tally("untrimmed", 0.0, face_id);
2244        }
2245    }
2246
2247    /// Attach a seam whose two columns the wire's own walk already found.
2248    fn record_columns(
2249        &mut self,
2250        edge: &Shape,
2251        forward: PlanarCurve,
2252        reversed: PlanarCurve,
2253        surface_id: ogeom_topo::SurfaceId,
2254        range: (f64, f64),
2255    ) -> OgeomResult<()> {
2256        ogeom_algo::attach_seam(
2257            &mut self.model,
2258            edge,
2259            forward,
2260            reversed,
2261            surface_id,
2262            Location::identity(),
2263            range,
2264        )
2265    }
2266
2267    /// Attach a derived pcurve, seaming it where the edge bounds the chart
2268    /// twice and the walk could not say where its other column stands.
2269    fn record_pcurve(
2270        &mut self,
2271        edge: &Shape,
2272        pcurve: PlanarCurve,
2273        surface_id: ogeom_topo::SurfaceId,
2274        seam: bool,
2275        range: (f64, f64),
2276    ) -> OgeomResult<()> {
2277        if seam {
2278            let Some(surface) = self.model.geometry().surface(surface_id).cloned() else {
2279                ogeom_bail!(Dangling, "the surface is not in this model");
2280            };
2281            // One side is where the projection landed; the other is one
2282            // period over, along the axis the surface actually closes on.
2283            let other = crate::pcurves::seam_other_side(&pcurve, range, &surface, self.tol)?;
2284            ogeom_algo::attach_seam(
2285                &mut self.model,
2286                edge,
2287                pcurve,
2288                other,
2289                surface_id,
2290                Location::identity(),
2291                range,
2292            )?;
2293        } else {
2294            ogeom_algo::attach_pcurve(
2295                &mut self.model,
2296                edge,
2297                pcurve,
2298                surface_id,
2299                Location::identity(),
2300                range,
2301            )?;
2302        }
2303        Ok(())
2304    }
2305
2306    /// Derive every pcurve this solid's faces will want, in parallel.
2307    ///
2308    /// Deriving one is a pure function of a curve, its range and a surface:
2309    /// it reads no model and depends on no order, and measured on a 330-solid
2310    /// assembly it is 95% of the time spent building solids. The walk that
2311    /// follows attaches them, in file order, exactly as it did when it derived
2312    /// them itself.
2313    ///
2314    /// Resolving *what* to derive still runs on the walk's own thread, since
2315    /// it builds edges and vertices into the model. That part is cheap; it is
2316    /// the projection and the fitting that are not.
2317    ///
2318    /// Best-effort by design: anything this cannot resolve is simply left out
2319    /// of the table, and `attach_pcurves` derives it the old way. So a face
2320    /// shape this does not anticipate costs time, never correctness.
2321    fn prepare_pcurves(&mut self, face_ids: &[u64]) {
2322        struct Job {
2323            face: u64,
2324            edge: u64,
2325            curve: Curve,
2326            range: (f64, f64),
2327            /// Index into `surfaces`. The surface is held once per face, not
2328            /// once per edge: a B-spline patch owns its whole control grid,
2329            /// and cloning that per edge costs more than the projection it
2330            /// was cloned for.
2331            surface: usize,
2332        }
2333        let mut surfaces: Vec<SurfaceGeometry> = Vec::new();
2334        let mut jobs: Vec<Job> = Vec::new();
2335        let mut seen: HashSet<(u64, u64)> = HashSet::new();
2336        for &fid in face_ids {
2337            let Ok(args) = self.face_args(fid) else {
2338                continue;
2339            };
2340            let Some(surface) = args
2341                .get(2)
2342                .and_then(Arg::reference)
2343                .and_then(|sid| self.surface(sid).ok().flatten())
2344            else {
2345                continue;
2346            };
2347            surfaces.push(surface);
2348            let at = surfaces.len() - 1;
2349            let bounds: Vec<u64> = args
2350                .get(1)
2351                .and_then(Arg::list)
2352                .unwrap_or(&[])
2353                .iter()
2354                .filter_map(Arg::reference)
2355                .collect();
2356            for bound in bounds {
2357                let Ok((loop_id, _)) = self.bound_args(bound) else {
2358                    continue;
2359                };
2360                let Ok(loop_args) = self.args(loop_id, "EDGE_LOOP") else {
2361                    continue;
2362                };
2363                let uses: Vec<u64> = loop_args
2364                    .get(1)
2365                    .and_then(Arg::list)
2366                    .unwrap_or(&[])
2367                    .iter()
2368                    .filter_map(Arg::reference)
2369                    .collect();
2370                for oe_id in uses {
2371                    let Ok(oargs) = self.args(oe_id, "ORIENTED_EDGE") else {
2372                        continue;
2373                    };
2374                    let Some(edge_id) = oargs.get(3).and_then(Arg::reference) else {
2375                        continue;
2376                    };
2377                    if !seen.insert((fid, edge_id)) {
2378                        continue;
2379                    }
2380                    if let Ok(Some((_, curve, range, _))) = self.edge(edge_id) {
2381                        jobs.push(Job {
2382                            face: fid,
2383                            edge: edge_id,
2384                            curve,
2385                            range,
2386                            surface: at,
2387                        });
2388                    }
2389                }
2390            }
2391        }
2392        // Below a handful of edges the threads cost more than the work; the
2393        // sequential path through `attach_pcurves` is already correct, so the
2394        // table is simply left empty and the walk derives them itself.
2395        if jobs.len() < 16 {
2396            return;
2397        }
2398        let tol = self.tol;
2399        let derived = ogeom_core::parallel::map_ordered(&jobs, |_, job| {
2400            let surface = &surfaces[job.surface];
2401            match ogeom_intersect::exact_pcurve_over(&job.curve, job.range, surface, tol) {
2402                Some(exact) => PreparedPcurve::Exact(exact),
2403                None => {
2404                    match crate::pcurves::fit_projected_pcurve(&job.curve, job.range, surface, tol)
2405                    {
2406                        Ok((curve, error, met, worst_off, warning)) => PreparedPcurve::Fitted {
2407                            curve,
2408                            error,
2409                            met,
2410                            worst_off,
2411                            warning,
2412                        },
2413                        Err(e) => PreparedPcurve::Refused(format!(
2414                            "face #{}: no pcurve for an edge on this surface ({e}); \
2415                         the face may not triangulate",
2416                            job.face
2417                        )),
2418                    }
2419                }
2420            }
2421        });
2422        for (job, pcurve) in jobs.iter().zip(derived) {
2423            self.pcurves.insert((job.face, job.edge), pcurve);
2424        }
2425    }
2426
2427    /// The solid a `MANIFOLD_SOLID_BREP` names: its shell's faces, sewn.
2428    ///
2429    /// `BREP_WITH_VOIDS` is the same entity with cavities: a subtype of
2430    /// `MANIFOLD_SOLID_BREP`, so its first two attributes are the name and
2431    /// the outer shell, and a third names the shells that bound the voids.
2432    /// A reader matching on the leading keyword alone does not see it, and
2433    /// the part simply vanishes: a printed housing with six cavities in
2434    /// it read as no body at all, its thirteen hundred faces with it. The
2435    /// voids join the solid as shells of their own, oriented as
2436    /// the file orients them, so every normal points away from the
2437    /// material: out of the body on the outside, into the cavity within.
2438    fn solid(&mut self, id: u64) -> OgeomResult<Shape> {
2439        let instance = self.instance(id)?;
2440        let args = instance
2441            .part("MANIFOLD_SOLID_BREP")
2442            .or_else(|| instance.part("BREP_WITH_VOIDS"))
2443            .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "#{id} is not a solid"))?
2444            .to_vec();
2445        let shell_id = args.get(1).and_then(Arg::reference).unwrap_or(0);
2446        let Some(shell) = self.shell(shell_id)? else {
2447            ogeom_bail!(Construction, "#{id}: a solid with no readable faces");
2448        };
2449        if !ogeom_algo::is_shell_closed(&self.model, &shell)? {
2450            self.report.warnings.push(format!(
2451                "#{id}: the shell does not close as read; measures needing an \
2452                 inside will refuse it"
2453            ));
2454        }
2455        let mut shells = vec![shell];
2456        for void_id in args
2457            .get(2)
2458            .and_then(Arg::list)
2459            .unwrap_or(&[])
2460            .iter()
2461            .filter_map(Arg::reference)
2462            .collect::<Vec<u64>>()
2463        {
2464            match self.shell(void_id) {
2465                Ok(Some(void)) => shells.push(void),
2466                Ok(None) => self.report.warnings.push(format!(
2467                    "#{id}: void shell #{void_id} has no readable faces; the \
2468                     cavity is missing from the solid"
2469                )),
2470                Err(refusal) => self.report.warnings.push(format!(
2471                    "#{id}: void shell #{void_id} could not be read ({refusal}); \
2472                     the cavity is missing from the solid"
2473                )),
2474            }
2475        }
2476        Ok(make_solid(&mut self.model, &shells)?.shape)
2477    }
2478
2479    /// The shape a `SHELL_BASED_SURFACE_MODEL` names: each of its shells
2480    /// sewn from its faces, a compound of them when there are several.
2481    ///
2482    /// A surface body is what a modeller exports for a part built from
2483    /// faces rather than from a solid (a motor coupler drawn as
2484    /// seventy-three single-face bodies is a real case), and a reader that
2485    /// walks only `MANIFOLD_SOLID_BREP` leaves such a part invisible. The
2486    /// shells stay shells: the file did not call them solids, and a closed
2487    /// one is still the file's surface model, not this reader's promotion.
2488    fn surface_model(&mut self, id: u64) -> OgeomResult<Shape> {
2489        let args = self.args(id, "SHELL_BASED_SURFACE_MODEL")?;
2490        let shell_ids: Vec<u64> = args
2491            .get(1)
2492            .and_then(Arg::list)
2493            .unwrap_or(&[])
2494            .iter()
2495            .filter_map(Arg::reference)
2496            .collect();
2497        let mut shells = Vec::new();
2498        for shell_id in shell_ids {
2499            if let Some(shell) = self.shell(shell_id)? {
2500                shells.push(shell);
2501            }
2502        }
2503        match shells.len() {
2504            0 => ogeom_bail!(
2505                Construction,
2506                "#{id}: a surface model with no readable faces"
2507            ),
2508            1 => Ok(shells.remove(0)),
2509            _ => Ok(ogeom_algo::build::make_compound(&mut self.model, &shells)?.shape),
2510        }
2511    }
2512
2513    /// The faces of a `CLOSED_SHELL` or `OPEN_SHELL`, sewn; `None` when not
2514    /// one of them could be read.
2515    ///
2516    /// An `ORIENTED_CLOSED_SHELL` is a use of another shell the other way
2517    /// round (how a solid's voids are named) and resolves to that shell,
2518    /// reversed when the use says so.
2519    fn shell(&mut self, shell_id: u64) -> OgeomResult<Option<Shape>> {
2520        let shell_instance = self.instance(shell_id)?;
2521        if let Some(oriented) = shell_instance
2522            .part("ORIENTED_CLOSED_SHELL")
2523            .map(<[Arg]>::to_vec)
2524        {
2525            let Some(base) = oriented.get(2).and_then(Arg::reference) else {
2526                ogeom_bail!(Construction, "#{shell_id}: an oriented shell names none");
2527            };
2528            let forward = !oriented.get(3).is_some_and(|a| a.is_enum("F"));
2529            return Ok(self
2530                .shell(base)?
2531                .map(|shell| if forward { shell } else { shell.reversed() }));
2532        }
2533        let shell_args = shell_instance
2534            .part("CLOSED_SHELL")
2535            .or_else(|| shell_instance.part("OPEN_SHELL"))
2536            .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "#{shell_id} is not a shell"))?
2537            .to_vec();
2538        let face_ids: Vec<u64> = shell_args
2539            .get(1)
2540            .and_then(Arg::list)
2541            .unwrap_or(&[])
2542            .iter()
2543            .filter_map(Arg::reference)
2544            .collect();
2545        self.prepare_pcurves(&face_ids);
2546        let mut faces = Vec::new();
2547        for fid in face_ids {
2548            if let Some(face) = self.face(fid)? {
2549                faces.push(face);
2550            }
2551        }
2552        if faces.is_empty() {
2553            return Ok(None);
2554        }
2555        Ok(Some(make_shell(&mut self.model, &faces)?.shape))
2556    }
2557
2558    // --- product structure, names, colours -----------------------------------
2559
2560    /// Assemble the document: the model, plus everything the file says about
2561    /// products, assemblies, placements and appearance.
2562    ///
2563    /// Takes the model out of the reader; geometry reading is over by the
2564    /// time structure is read. Structure that resists becomes a warning and a
2565    /// flat document, never an error: the geometry is already good, and a
2566    /// mangled product tree should not take it down.
2567    fn document(
2568        &mut self,
2569        by_item: &HashMap<u64, Shape>,
2570        solids: &[Shape],
2571        shells: &[Shape],
2572    ) -> OgeomResult<ogeom_doc::Document> {
2573        // The graph is walked before the model moves, because frames scale
2574        // through the reader's own unit handling.
2575        let structure = self.product_structure(by_item);
2576        let colours = self.colours(by_item);
2577        let pmi = self.pmi_of();
2578
2579        let mut document = ogeom_doc::Document::over(std::mem::take(&mut self.model));
2580        match structure {
2581            Some(products) => self.build_products(&mut document, products),
2582            None => {
2583                for (i, solid) in solids.iter().enumerate() {
2584                    document.add_part(format!("solid-{i}"), solid.clone());
2585                }
2586                for (i, shell) in shells.iter().enumerate() {
2587                    document.add_part(format!("shell-{i}"), shell.clone());
2588                }
2589            }
2590        }
2591        for (shape, colour) in colours {
2592            document.set_colour(&shape, colour);
2593        }
2594        *document.pmi_mut() = pmi;
2595        for view in self.views() {
2596            document.add_view(view);
2597        }
2598        Ok(document)
2599    }
2600
2601    /// The file's product graph, or `None` when it has none worth the name.
2602    fn product_structure(&mut self, by_item: &HashMap<u64, Shape>) -> Option<Vec<PdEntry>> {
2603        // PRODUCT_DEFINITION -> name, via formation and product.
2604        let mut pds: Vec<u64> = self
2605            .exchange
2606            .data
2607            .iter()
2608            .filter(|(_, inst)| inst.part("PRODUCT_DEFINITION").is_some())
2609            .filter(|(_, inst)| inst.part("PRODUCT_DEFINITION_RELATIONSHIP").is_none())
2610            .map(|(id, _)| *id)
2611            .collect();
2612        pds.sort_unstable();
2613        if pds.is_empty() {
2614            return None;
2615        }
2616
2617        // SHAPE_DEFINITION_REPRESENTATION: definition (a PRODUCT_DEFINITION_SHAPE
2618        // over a PD or a usage) -> shape representation.
2619        let mut sr_of_pd: HashMap<u64, u64> = HashMap::new();
2620        let sdrs: Vec<(u64, u64)> = self
2621            .exchange
2622            .data
2623            .iter()
2624            .filter(|(_, inst)| inst.part("SHAPE_DEFINITION_REPRESENTATION").is_some())
2625            .filter_map(|(id, _)| {
2626                let args = self.args(*id, "SHAPE_DEFINITION_REPRESENTATION").ok()?;
2627                Some((args.first()?.reference()?, args.get(1)?.reference()?))
2628            })
2629            .collect();
2630        for (pds_id, sr) in sdrs {
2631            if let Some(definition) = self.definition_of_shape(pds_id) {
2632                sr_of_pd.insert(definition, sr);
2633            }
2634        }
2635
2636        // A product's solids may live one representation over: AP203 files
2637        // routinely tie the product to a bare axis representation and hang
2638        // the B-rep off it through a plain SHAPE_REPRESENTATION_RELATIONSHIP.
2639        // Only the plain ones are followed; the transformation-carrying kind
2640        // is an assembly edge, and following it would leak one product's
2641        // geometry into another.
2642        let mut linked: HashMap<u64, Vec<u64>> = HashMap::new();
2643        let mut srrs: Vec<u64> = self
2644            .exchange
2645            .data
2646            .iter()
2647            .filter(|(_, inst)| {
2648                inst.part("SHAPE_REPRESENTATION_RELATIONSHIP").is_some()
2649                    && inst
2650                        .part("REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION")
2651                        .is_none()
2652            })
2653            .map(|(id, _)| *id)
2654            .collect();
2655        srrs.sort_unstable();
2656        for srr in srrs {
2657            let args = {
2658                let Ok(instance) = self.instance(srr) else {
2659                    continue;
2660                };
2661                let Some(args) = instance
2662                    .part("SHAPE_REPRESENTATION_RELATIONSHIP")
2663                    .or_else(|| instance.part("REPRESENTATION_RELATIONSHIP"))
2664                else {
2665                    continue;
2666                };
2667                args.to_vec()
2668            };
2669            if let (Some(a), Some(b)) = (
2670                args.get(2).and_then(Arg::reference),
2671                args.get(3).and_then(Arg::reference),
2672            ) {
2673                linked.entry(a).or_default().push(b);
2674                linked.entry(b).or_default().push(a);
2675            }
2676        }
2677
2678        let mut entries = Vec::new();
2679        for pd in pds {
2680            let name = self.product_name(pd).unwrap_or_else(|| format!("#{pd}"));
2681            let mut shapes: Vec<Shape> = Vec::new();
2682            if let Some(&sr) = sr_of_pd.get(&pd) {
2683                let mut reps = vec![sr];
2684                reps.extend(linked.get(&sr).into_iter().flatten().copied());
2685                for rep in reps {
2686                    if let Some(items) = self.representation_items(rep) {
2687                        shapes.extend(items.iter().filter_map(|item| by_item.get(item).cloned()));
2688                    }
2689                }
2690            }
2691            entries.push(PdEntry {
2692                pd,
2693                name,
2694                shapes,
2695                children: Vec::new(),
2696            });
2697        }
2698
2699        // NEXT_ASSEMBLY_USAGE_OCCURRENCE: parent -> child, with the placement
2700        // recovered from the CONTEXT_DEPENDENT_SHAPE_REPRESENTATION over it.
2701        let mut nauos: Vec<u64> = self
2702            .exchange
2703            .data
2704            .iter()
2705            .filter(|(_, inst)| inst.part("NEXT_ASSEMBLY_USAGE_OCCURRENCE").is_some())
2706            .map(|(id, _)| *id)
2707            .collect();
2708        nauos.sort_unstable();
2709        let entry_of_pd: HashMap<u64, usize> =
2710            entries.iter().enumerate().map(|(i, e)| (e.pd, i)).collect();
2711        for nauo in nauos {
2712            let Ok(args) = self.args(nauo, "NEXT_ASSEMBLY_USAGE_OCCURRENCE") else {
2713                continue;
2714            };
2715            let (Some(parent), Some(child)) = (
2716                args.get(3).and_then(Arg::reference),
2717                args.get(4).and_then(Arg::reference),
2718            ) else {
2719                continue;
2720            };
2721            let name = args
2722                .get(5)
2723                .and_then(|a| match a {
2724                    Arg::Str(s) if !s.is_empty() => Some(s.clone()),
2725                    _ => None,
2726                })
2727                .or_else(|| {
2728                    self.args(nauo, "NEXT_ASSEMBLY_USAGE_OCCURRENCE")
2729                        .ok()
2730                        .and_then(|args| match args.get(1) {
2731                            Some(Arg::Str(s)) if !s.is_empty() => Some(s.clone()),
2732                            _ => None,
2733                        })
2734                });
2735            let child_sr = sr_of_pd.get(&child).copied();
2736            let at = self
2737                .usage_transform(nauo, child_sr)
2738                .unwrap_or(Transform::IDENTITY);
2739            if let Some(&at_index) = entry_of_pd.get(&parent) {
2740                entries[at_index].children.push((child, at, name));
2741            }
2742        }
2743        Some(entries)
2744    }
2745
2746    /// The `PRODUCT_DEFINITION` (or usage) a `PRODUCT_DEFINITION_SHAPE` is over.
2747    fn definition_of_shape(&mut self, pds_id: u64) -> Option<u64> {
2748        let args = self.args(pds_id, "PRODUCT_DEFINITION_SHAPE").ok()?;
2749        args.get(2).and_then(Arg::reference)
2750    }
2751
2752    /// A product definition's product name.
2753    fn product_name(&mut self, pd: u64) -> Option<String> {
2754        let formation = self
2755            .args(pd, "PRODUCT_DEFINITION")
2756            .ok()?
2757            .get(2)
2758            .and_then(Arg::reference)?;
2759        // The formation, plain or with its source named (a mesh converter's
2760        // habit, and a modeller's for an assembly's parts) carries the
2761        // product in its third slot either way, whether it stands alone or
2762        // as one part of a complex instance.
2763        let product = [
2764            "PRODUCT_DEFINITION_FORMATION",
2765            "PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE",
2766        ]
2767        .into_iter()
2768        .find_map(|keyword| {
2769            self.instance(formation)
2770                .ok()?
2771                .part(keyword)
2772                .map(<[Arg]>::to_vec)
2773                .or_else(|| self.args(formation, keyword).ok())?
2774                .get(2)
2775                .and_then(Arg::reference)
2776        })?;
2777        // The product's name, or its id where the name is blank: a mesh
2778        // converter fills the id and leaves the name empty.
2779        let args = self.args(product, "PRODUCT").ok()?;
2780        [args.get(1), args.first()]
2781            .into_iter()
2782            .find_map(|arg| match arg {
2783                Some(Arg::Str(name)) if !name.is_empty() => Some(name.clone()),
2784                _ => None,
2785            })
2786    }
2787
2788    /// A representation's item references.
2789    fn representation_items(&mut self, sr: u64) -> Option<Vec<u64>> {
2790        let instance = self.instance(sr).ok()?;
2791        // Every subtype a file names a shape representation by: the B-rep
2792        // kinds, the surface-model kinds a mesh converter writes, and the
2793        // plain one.
2794        let args = [
2795            "SHAPE_REPRESENTATION",
2796            "ADVANCED_BREP_SHAPE_REPRESENTATION",
2797            "MANIFOLD_SURFACE_SHAPE_REPRESENTATION",
2798            "FACETED_BREP_SHAPE_REPRESENTATION",
2799            "GEOMETRICALLY_BOUNDED_SURFACE_SHAPE_REPRESENTATION",
2800            "GEOMETRICALLY_BOUNDED_WIREFRAME_SHAPE_REPRESENTATION",
2801            "REPRESENTATION",
2802        ]
2803        .into_iter()
2804        .find_map(|keyword| instance.part(keyword))?
2805        .to_vec();
2806        Some(
2807            args.get(1)?
2808                .list()?
2809                .iter()
2810                .filter_map(Arg::reference)
2811                .collect(),
2812        )
2813    }
2814
2815    /// The placement a usage's `CONTEXT_DEPENDENT_SHAPE_REPRESENTATION` states.
2816    ///
2817    /// The transformation aligns an axis placement in the child's space with
2818    /// one in the parent's; which item is which follows from which side of
2819    /// the representation relationship is the child's own shape
2820    /// representation, not from argument order, because real files disagree
2821    /// about the order.
2822    fn usage_transform(&mut self, nauo: u64, child_sr: Option<u64>) -> Option<Transform> {
2823        if self.cdsr_of_nauo.is_none() {
2824            // One pass over the CDSRs, each resolved to the usage it
2825            // describes; ascending id order so a usage described twice keeps
2826            // the same one the old lowest-id-first scan chose.
2827            let mut cdsrs: Vec<u64> = self
2828                .exchange
2829                .data
2830                .iter()
2831                .filter(|(_, inst)| {
2832                    inst.part("CONTEXT_DEPENDENT_SHAPE_REPRESENTATION")
2833                        .is_some()
2834                })
2835                .map(|(id, _)| *id)
2836                .collect();
2837            cdsrs.sort_unstable();
2838            let mut index: HashMap<u64, u64> = HashMap::new();
2839            for id in cdsrs {
2840                let Some(owner) = self
2841                    .args(id, "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION")
2842                    .ok()
2843                    .and_then(|args| args.get(1).and_then(Arg::reference))
2844                    .and_then(|pds| self.definition_of_shape(pds))
2845                else {
2846                    continue;
2847                };
2848                index.entry(owner).or_insert(id);
2849            }
2850            self.cdsr_of_nauo = Some(index);
2851        }
2852        let cdsr = *self.cdsr_of_nauo.as_ref()?.get(&nauo)?;
2853        let rr = self
2854            .args(cdsr, "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION")
2855            .ok()?
2856            .first()
2857            .and_then(Arg::reference)?;
2858        let (rep_1, rep_2) = {
2859            let args = self.args(rr, "REPRESENTATION_RELATIONSHIP").ok()?;
2860            (
2861                args.get(2).and_then(Arg::reference)?,
2862                args.get(3).and_then(Arg::reference)?,
2863            )
2864        };
2865        let idt = self
2866            .args(rr, "REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION")
2867            .ok()?
2868            .first()
2869            .and_then(Arg::reference)?;
2870        let (item_1, item_2) = {
2871            let args = self.args(idt, "ITEM_DEFINED_TRANSFORMATION").ok()?;
2872            (
2873                args.get(2).and_then(Arg::reference)?,
2874                args.get(3).and_then(Arg::reference)?,
2875            )
2876        };
2877        let frame_1 = self.frame(item_1).ok()?;
2878        let frame_2 = self.frame(item_2).ok()?;
2879        // item_1 pairs with rep_1. When rep_1 is the child's representation,
2880        // the child's frame_1 lands on the parent's frame_2.
2881        let child_first = match child_sr {
2882            Some(sr) => rep_1 == sr || rep_2 != sr,
2883            None => true,
2884        };
2885        Some(if child_first {
2886            Transform::from_frame(&frame_2) * Transform::to_frame(&frame_1)
2887        } else {
2888            Transform::from_frame(&frame_1) * Transform::to_frame(&frame_2)
2889        })
2890    }
2891
2892    /// Products into the document: assemblies for the parents, parts for the
2893    /// shaped, instances for the usage edges.
2894    fn build_products(&mut self, document: &mut ogeom_doc::Document, entries: Vec<PdEntry>) {
2895        let mut ids: HashMap<u64, ogeom_doc::ProductId> = HashMap::new();
2896        for entry in &entries {
2897            let shape = match entry.shapes.len() {
2898                0 => None,
2899                1 => Some(entry.shapes[0].clone()),
2900                _ => match ogeom_algo::build::make_compound(document.model_mut(), &entry.shapes) {
2901                    Ok(built) => Some(built.shape),
2902                    Err(_) => Some(entry.shapes[0].clone()),
2903                },
2904            };
2905            if entry.children.is_empty() {
2906                if let Some(shape) = shape {
2907                    ids.insert(entry.pd, document.add_part(&entry.name, shape));
2908                }
2909                // A product with neither shape nor children holds nothing a
2910                // document can say; it is left out.
2911            } else {
2912                let assembly = document.add_assembly(&entry.name);
2913                ids.insert(entry.pd, assembly);
2914                // An assembly with its own geometry keeps it as a body part
2915                // placed at identity: rare, but files do it.
2916                if let Some(shape) = shape {
2917                    let body = document.add_part(format!("{}-body", entry.name), shape);
2918                    let _ = document.add_instance(assembly, body, Transform::IDENTITY, None);
2919                }
2920            }
2921        }
2922        for entry in &entries {
2923            let Some(&parent) = ids.get(&entry.pd) else {
2924                continue;
2925            };
2926            for (child, at, name) in &entry.children {
2927                let Some(&child_id) = ids.get(child) else {
2928                    self.report.warnings.push(format!(
2929                        "#{child}: an assembly child holds nothing readable"
2930                    ));
2931                    continue;
2932                };
2933                if let Err(e) = document.add_instance(parent, child_id, *at, name.clone()) {
2934                    self.report
2935                        .warnings
2936                        .push(format!("assembly edge #{} -> #{child}: {e}", entry.pd));
2937                }
2938            }
2939        }
2940    }
2941
2942    /// Colours from styled items, keyed to the shapes they style.
2943    fn colours(&mut self, by_item: &HashMap<u64, Shape>) -> Vec<(Shape, ogeom_doc::Colour)> {
2944        let mut styled: Vec<u64> = self
2945            .exchange
2946            .data
2947            .iter()
2948            .filter(|(_, inst)| {
2949                inst.part("STYLED_ITEM").is_some() || inst.part("OVER_RIDING_STYLED_ITEM").is_some()
2950            })
2951            .map(|(id, _)| *id)
2952            .collect();
2953        // Sorted so an item styled twice resolves the same way every run;
2954        // overriding styles carry higher instance numbers in practice, and a
2955        // later same-shape entry wins the map insertion downstream.
2956        styled.sort_unstable();
2957        let mut out = Vec::new();
2958        for id in styled {
2959            let args = {
2960                let Ok(instance) = self.instance(id) else {
2961                    continue;
2962                };
2963                let Some(args) = instance
2964                    .part("STYLED_ITEM")
2965                    .or_else(|| instance.part("OVER_RIDING_STYLED_ITEM"))
2966                else {
2967                    continue;
2968                };
2969                args.to_vec()
2970            };
2971            let Some(item) = args.get(2).and_then(Arg::reference) else {
2972                continue;
2973            };
2974            let Some(shape) = by_item.get(&item).or_else(|| self.faces.get(&item)) else {
2975                continue;
2976            };
2977            let styles: Vec<u64> = args
2978                .get(1)
2979                .and_then(Arg::list)
2980                .map(|list| list.iter().filter_map(Arg::reference).collect())
2981                .unwrap_or_default();
2982            let shape = shape.clone();
2983            if let Some(colour) = styles.iter().find_map(|&style| self.colour_in(style, 0)) {
2984                out.push((shape, colour));
2985            }
2986        }
2987        out
2988    }
2989
2990    /// The first `COLOUR_RGB` reachable from a presentation style, depth-bounded.
2991    ///
2992    /// The styled-item chain has five links and real files rearrange them, so
2993    /// the walk follows references rather than the textbook path.
2994    fn colour_in(&mut self, id: u64, depth: usize) -> Option<ogeom_doc::Colour> {
2995        if depth > 6 {
2996            return None;
2997        }
2998        let (keyword, args) = {
2999            let instance = self.instance(id).ok()?;
3000            let all: Vec<Arg> = instance
3001                .parts()
3002                .flat_map(|(_, args)| args.iter().cloned())
3003                .collect();
3004            (instance.keyword().to_owned(), all)
3005        };
3006        if keyword == "COLOUR_RGB" {
3007            let channel = |i: usize| args.get(i).and_then(Arg::number);
3008            return Some(ogeom_doc::Colour::rgb(
3009                channel(1)?,
3010                channel(2)?,
3011                channel(3)?,
3012            ));
3013        }
3014        let mut refs: Vec<u64> = Vec::new();
3015        collect_refs(&args, &mut refs);
3016        refs.into_iter()
3017            .find_map(|next| self.colour_in(next, depth + 1))
3018    }
3019
3020    // --- semantic PMI --------------------------------------------------------
3021
3022    /// The file's semantic PMI: dimensions, geometric tolerances, datums.
3023    ///
3024    /// Annotations that resist stay out with a warning; PMI never takes the
3025    /// geometry down.
3026    fn pmi_of(&mut self) -> ogeom_doc::Pmi {
3027        let mut pmi = ogeom_doc::Pmi::new();
3028        // Which STEP instance each annotation came from, so the presentation
3029        // pass can name the annotation a callout draws exactly rather than by
3030        // matching a string two annotations may share.
3031        let mut annotation_ids: HashMap<u64, ogeom_doc::Annotated> = HashMap::new();
3032
3033        // Which topology each shape aspect describes: directly through
3034        // GEOMETRIC_ITEM_SPECIFIC_USAGE, and one relationship step outward,
3035        // because composite aspects hold their pieces through relationships.
3036        let mut aspect_items: HashMap<u64, Vec<ogeom_topo::TShapeId>> = HashMap::new();
3037        let mut gisus = self.ids_with("GEOMETRIC_ITEM_SPECIFIC_USAGE");
3038        gisus.sort_unstable();
3039        for id in gisus {
3040            let Ok(args) = self.args(id, "GEOMETRIC_ITEM_SPECIFIC_USAGE") else {
3041                continue;
3042            };
3043            let (Some(aspect), Some(item)) = (
3044                args.get(2).and_then(Arg::reference),
3045                args.get(4).and_then(Arg::reference),
3046            ) else {
3047                continue;
3048            };
3049            let node = self
3050                .faces
3051                .get(&item)
3052                .map(Shape::node)
3053                .or_else(|| self.edges.get(&item).map(|(shape, ..)| shape.node()));
3054            if let Some(node) = node {
3055                aspect_items.entry(aspect).or_default().push(node);
3056            }
3057        }
3058        let mut adjacency: HashMap<u64, Vec<u64>> = HashMap::new();
3059        for id in self.ids_with("SHAPE_ASPECT_RELATIONSHIP") {
3060            let Ok(args) = self.args(id, "SHAPE_ASPECT_RELATIONSHIP") else {
3061                continue;
3062            };
3063            if let (Some(a), Some(b)) = (
3064                args.get(2).and_then(Arg::reference),
3065                args.get(3).and_then(Arg::reference),
3066            ) {
3067                adjacency.entry(a).or_default().push(b);
3068                adjacency.entry(b).or_default().push(a);
3069            }
3070        }
3071        let items_for = |aspect: u64| -> Vec<ogeom_topo::TShapeId> {
3072            // Three relationship steps: a composite aspect holds components,
3073            // a derived aspect sits behind a composite, and a datum one link
3074            // behind its features: the deepest chain the corpus exhibits.
3075            let mut reach = vec![aspect];
3076            for _ in 0..3 {
3077                let mut next = reach.clone();
3078                for a in &reach {
3079                    next.extend(adjacency.get(a).into_iter().flatten().copied());
3080                }
3081                next.sort_unstable();
3082                next.dedup();
3083                reach = next;
3084            }
3085            let mut out: Vec<ogeom_topo::TShapeId> = Vec::new();
3086            for a in reach {
3087                out.extend(aspect_items.get(&a).into_iter().flatten().copied());
3088            }
3089            out.sort_unstable();
3090            out.dedup();
3091            out
3092        };
3093
3094        // Dimensions: characteristic -> representation, values from the
3095        // measure items, bounds from any plus/minus tolerance over the same
3096        // characteristic.
3097        let mut plus_minus: HashMap<u64, (Option<f64>, Option<f64>)> = HashMap::new();
3098        for id in self.ids_with("PLUS_MINUS_TOLERANCE") {
3099            let Ok(args) = self.args(id, "PLUS_MINUS_TOLERANCE") else {
3100                continue;
3101            };
3102            let (Some(tv), Some(dim)) = (
3103                args.first().and_then(Arg::reference),
3104                args.get(1).and_then(Arg::reference),
3105            ) else {
3106                continue;
3107            };
3108            let Ok(tv_args) = self.args(tv, "TOLERANCE_VALUE") else {
3109                continue;
3110            };
3111            let lower = tv_args
3112                .first()
3113                .and_then(Arg::reference)
3114                .and_then(|r| self.measure_value(r))
3115                .map(|(v, _)| v);
3116            let upper = tv_args
3117                .get(1)
3118                .and_then(Arg::reference)
3119                .and_then(|r| self.measure_value(r))
3120                .map(|(v, _)| v);
3121            plus_minus.insert(dim, (lower, upper));
3122        }
3123        let mut dcrs = self.ids_with("DIMENSIONAL_CHARACTERISTIC_REPRESENTATION");
3124        dcrs.sort_unstable();
3125        for id in dcrs {
3126            let Ok(args) = self.args(id, "DIMENSIONAL_CHARACTERISTIC_REPRESENTATION") else {
3127                continue;
3128            };
3129            let (Some(dim), Some(sdr)) = (
3130                args.first().and_then(Arg::reference),
3131                args.get(1).and_then(Arg::reference),
3132            ) else {
3133                continue;
3134            };
3135            let mut values = Vec::new();
3136            let mut kind = ogeom_doc::MeasureKind::Length;
3137            if let Ok(sdr_args) = self.args(sdr, "SHAPE_DIMENSION_REPRESENTATION") {
3138                for item in sdr_args.get(1).and_then(Arg::list).unwrap_or(&[]) {
3139                    if let Some(r) = item.reference()
3140                        && let Some((v, k)) = self.measure_value(r)
3141                    {
3142                        values.push(v);
3143                        kind = k;
3144                    }
3145                }
3146            }
3147            let (name, location, aspects) = self.dimension_shape(dim);
3148            let features: Vec<Vec<ogeom_topo::TShapeId>> =
3149                aspects.into_iter().map(&items_for).collect();
3150            let (minus, plus) = plus_minus.get(&dim).copied().unwrap_or((None, None));
3151            annotation_ids.insert(dim, ogeom_doc::Annotated::Dimension(pmi.dimensions.len()));
3152            pmi.dimensions.push(ogeom_doc::Dimension {
3153                name,
3154                values,
3155                kind,
3156                plus,
3157                minus,
3158                features,
3159                location,
3160            });
3161        }
3162
3163        // Geometric tolerances: any instance whose subtype names one. The
3164        // complex form keeps its attributes on the GEOMETRIC_TOLERANCE part;
3165        // the simple form flattens them into the subtype's own list.
3166        let is_subtype = |k: &str| {
3167            k.ends_with("_TOLERANCE")
3168                && !matches!(
3169                    k,
3170                    "GEOMETRIC_TOLERANCE"
3171                        | "GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE"
3172                        | "GEOMETRIC_TOLERANCE_WITH_DEFINED_UNIT"
3173                        | "GEOMETRIC_TOLERANCE_WITH_MODIFIERS"
3174                        | "GEOMETRIC_TOLERANCE_WITH_MAXIMUM_TOLERANCE"
3175                        | "PLUS_MINUS_TOLERANCE"
3176                        | "TOLERANCE_VALUE"
3177                )
3178        };
3179        let mut gts: Vec<u64> = self
3180            .exchange
3181            .data
3182            .iter()
3183            .filter(|(_, inst)| inst.parts().any(|(k, _)| is_subtype(k)))
3184            .map(|(id, _)| *id)
3185            .collect();
3186        gts.sort_unstable();
3187        for id in gts {
3188            let (name, magnitude_ref, aspect, kind, modifiers, datum_refs) = {
3189                let Ok(instance) = self.instance(id) else {
3190                    continue;
3191                };
3192                let subtype = instance
3193                    .parts()
3194                    .map(|(k, _)| k.to_owned())
3195                    .find(|k| is_subtype(k));
3196                let Some(subtype) = subtype else {
3197                    continue;
3198                };
3199                let base = instance
3200                    .part("GEOMETRIC_TOLERANCE")
3201                    .or_else(|| instance.part(&subtype))
3202                    .unwrap_or(&[])
3203                    .to_vec();
3204                let name = match base.first() {
3205                    Some(Arg::Str(s)) => s.clone(),
3206                    _ => String::new(),
3207                };
3208                let magnitude_ref = base.get(2).and_then(Arg::reference);
3209                let aspect = base.get(3).and_then(Arg::reference);
3210                let kind = Some(subtype.trim_end_matches("_TOLERANCE").to_lowercase());
3211                // Modifiers ride on their own part in the complex form, as
3212                // a list of enumeration words.
3213                let modifiers: Vec<String> = instance
3214                    .part("GEOMETRIC_TOLERANCE_WITH_MODIFIERS")
3215                    .and_then(|args| args.first())
3216                    .and_then(Arg::list)
3217                    .map(|list| {
3218                        list.iter()
3219                            .filter_map(|arg| match arg {
3220                                Arg::Enum(word) => Some(word.to_lowercase()),
3221                                _ => None,
3222                            })
3223                            .collect()
3224                    })
3225                    .unwrap_or_default();
3226                // The complex form keeps the datum list on its own part;
3227                // the simple form appends it as the subtype's fifth argument.
3228                let datum_refs: Vec<u64> = instance
3229                    .part("GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE")
3230                    .and_then(|args| args.first())
3231                    .and_then(Arg::list)
3232                    .or_else(|| base.get(4).and_then(Arg::list))
3233                    .map(|list| list.iter().filter_map(Arg::reference).collect())
3234                    .unwrap_or_default();
3235                (name, magnitude_ref, aspect, kind, modifiers, datum_refs)
3236            };
3237            let Some(kind) = kind else {
3238                continue;
3239            };
3240            let magnitude = magnitude_ref
3241                .and_then(|r| self.measure_value(r))
3242                .map_or(0.0, |(v, _)| v);
3243            let datums: Vec<String> = datum_refs
3244                .iter()
3245                .filter_map(|&r| self.datum_letter(r, 0))
3246                .collect();
3247            let items = aspect.map(items_for).unwrap_or_default();
3248            annotation_ids.insert(id, ogeom_doc::Annotated::Tolerance(pmi.tolerances.len()));
3249            pmi.tolerances.push(ogeom_doc::GeometricTolerance {
3250                kind,
3251                name,
3252                magnitude,
3253                modifiers,
3254                datums,
3255                items,
3256            });
3257        }
3258
3259        // Datums: the letters, with the features they mark reached through
3260        // the aspect graph.
3261        let mut datums = self.ids_with("DATUM");
3262        datums.sort_unstable();
3263        for id in datums {
3264            let letter = {
3265                let Ok(instance) = self.instance(id) else {
3266                    continue;
3267                };
3268                if instance.part("DATUM_FEATURE").is_some()
3269                    || instance.part("DATUM_REFERENCE").is_some()
3270                    || instance.part("DATUM_REFERENCE_COMPARTMENT").is_some()
3271                    || instance.part("DATUM_SYSTEM").is_some()
3272                {
3273                    continue;
3274                }
3275                let Some(args) = instance.part("DATUM") else {
3276                    continue;
3277                };
3278                match args.get(4) {
3279                    Some(Arg::Str(s)) if !s.is_empty() => s.clone(),
3280                    _ => continue,
3281                }
3282            };
3283            annotation_ids.insert(id, ogeom_doc::Annotated::Datum(pmi.datums.len()));
3284            pmi.datums.push(ogeom_doc::Datum {
3285                label: letter,
3286                items: items_for(id),
3287            });
3288        }
3289
3290        // Datum targets: the pads a datum is actually established at. The
3291        // target's identifier is the letter's number (`A1` is target 1 of
3292        // datum A), and its placement and size come through the shape
3293        // representation the feature is associated with.
3294        let mut targets = self.ids_with("PLACED_DATUM_TARGET_FEATURE");
3295        targets.sort_unstable();
3296        for id in targets {
3297            if let Some(target) = self.datum_target(id, &items_for) {
3298                pmi.targets.push(target);
3299            }
3300        }
3301
3302        // Presentation: what a viewer draws. A callout holds tessellated
3303        // annotation occurrences, each an indexed set of polylines over a
3304        // coordinates list; an annotation plane says which plane they are
3305        // drawn in and which callouts it holds; and a model item association
3306        // says which semantic annotation a callout is the picture of.
3307        let (callouts, callout_index) = self.callouts(&annotation_ids);
3308        pmi.callouts = callouts;
3309        self.callout_index = callout_index;
3310        pmi
3311    }
3312
3313    /// Saved views: every *named* draughting model is one; the unnamed one
3314    /// is the annotation-plane container this writer emits itself. The
3315    /// camera item gives the frame; the callout items give the subset.
3316    fn views(&mut self) -> Vec<ogeom_doc::View> {
3317        let mut out = Vec::new();
3318        for id in self.ids_with("DRAUGHTING_MODEL") {
3319            let Ok(args) = self.args(id, "DRAUGHTING_MODEL") else {
3320                continue;
3321            };
3322            let name = match args.first() {
3323                Some(Arg::Str(text)) => text.clone(),
3324                _ => String::new(),
3325            };
3326            if name.is_empty() {
3327                continue;
3328            }
3329            let mut frame = None;
3330            let mut callouts = Vec::new();
3331            for item in args.get(1).and_then(Arg::list).unwrap_or(&[]) {
3332                let Some(item) = item.reference() else {
3333                    continue;
3334                };
3335                if let Ok(cam) = self.args(item, "CAMERA_MODEL_D3") {
3336                    if let Some(placement) = cam.get(1).and_then(Arg::reference) {
3337                        frame = self.frame(placement).ok();
3338                    }
3339                    continue;
3340                }
3341                if let Some(&index) = self.callout_index.get(&item) {
3342                    callouts.push(index);
3343                }
3344            }
3345            let Some(frame) = frame else { continue };
3346            out.push(ogeom_doc::View {
3347                name,
3348                frame,
3349                clipping: None,
3350                callouts,
3351            });
3352        }
3353        out
3354    }
3355
3356    /// One placed datum target: which datum, which number, where and how big.
3357    fn datum_target(
3358        &mut self,
3359        id: u64,
3360        items_for: &dyn Fn(u64) -> Vec<ogeom_topo::TShapeId>,
3361    ) -> Option<ogeom_doc::DatumTarget> {
3362        let (target_id, description) = {
3363            let instance = self.instance(id).ok()?;
3364            let args = instance.part("PLACED_DATUM_TARGET_FEATURE")?;
3365            let target = match args.get(4) {
3366                Some(Arg::Str(s)) if !s.is_empty() => s.clone(),
3367                _ => return None,
3368            };
3369            let description = match args.get(1) {
3370                Some(Arg::Str(s)) => s.to_ascii_lowercase(),
3371                _ => String::new(),
3372            };
3373            (target, description)
3374        };
3375        // `A1`: the letter is the datum, the digits its number.
3376        let split = target_id
3377            .find(|c: char| c.is_ascii_digit())
3378            .unwrap_or(target_id.len());
3379        let (letter, number) = target_id.split_at(split);
3380        let index = number.parse::<u32>().unwrap_or(1);
3381        let datum = if letter.is_empty() {
3382            self.datum_letter(id, 0).unwrap_or_default()
3383        } else {
3384            letter.to_owned()
3385        };
3386
3387        // The target's placement and size live in a shape representation the
3388        // target's own property definition names. The lengths come in the
3389        // file's own unit, as every length does. Both hops go through the
3390        // indexes: the old form rescanned every entity per property *per
3391        // target*, the assembly quadratic one storey deeper.
3392        self.ensure_property_indexes();
3393        let mut frame = None;
3394        let mut lengths: Vec<f64> = Vec::new();
3395        let properties: Vec<u64> = self
3396            .properties_of_definition
3397            .as_ref()
3398            .and_then(|m| m.get(&id).cloned())
3399            .unwrap_or_default();
3400        for property in properties {
3401            let sdrs: Vec<u64> = self
3402                .sdrs_of_property
3403                .as_ref()
3404                .and_then(|m| m.get(&property).cloned())
3405                .unwrap_or_default();
3406            for sdr in sdrs {
3407                let Ok(args) = self.args(sdr, "SHAPE_DEFINITION_REPRESENTATION") else {
3408                    continue;
3409                };
3410                let Some(rep) = args.get(1).and_then(Arg::reference) else {
3411                    continue;
3412                };
3413                for item in self.representation_items(rep).unwrap_or_default() {
3414                    let keyword = self
3415                        .instance(item)
3416                        .map(|i| i.keyword().to_owned())
3417                        .unwrap_or_default();
3418                    match keyword.as_str() {
3419                        "AXIS2_PLACEMENT_3D" => frame = self.frame(item).ok(),
3420                        "LENGTH_MEASURE_WITH_UNIT" | "MEASURE_REPRESENTATION_ITEM" => {
3421                            if let Some((value, _)) = self.measure_value(item) {
3422                                lengths.push(value);
3423                            }
3424                        }
3425                        _ => {}
3426                    }
3427                }
3428            }
3429        }
3430
3431        // What the target *is* follows from the description the file gives
3432        // and how many sizes it carries: an area needs two, a circle one, a
3433        // line one, a point none.
3434        let kind = if description.contains("circle") || description.contains("circular") {
3435            ogeom_doc::DatumTargetKind::Circle {
3436                diameter: lengths.first().copied().unwrap_or(0.0),
3437            }
3438        } else if description.contains("rectangle") || lengths.len() >= 2 {
3439            ogeom_doc::DatumTargetKind::Rectangle {
3440                length: lengths.first().copied().unwrap_or(0.0),
3441                width: lengths.get(1).copied().unwrap_or(0.0),
3442            }
3443        } else if description.contains("line") || lengths.len() == 1 {
3444            ogeom_doc::DatumTargetKind::Line {
3445                length: lengths.first().copied().unwrap_or(0.0),
3446            }
3447        } else {
3448            ogeom_doc::DatumTargetKind::Point
3449        };
3450        Some(ogeom_doc::DatumTarget {
3451            datum,
3452            index,
3453            kind,
3454            at: frame.map_or(Point::ORIGIN, |f: Frame| f.origin()),
3455            frame,
3456            items: items_for(id),
3457        })
3458    }
3459
3460    /// The drawn annotations: callouts, their polylines, their planes, and
3461    /// which semantic annotation each one draws.
3462    fn callouts(
3463        &mut self,
3464        annotation_ids: &HashMap<u64, ogeom_doc::Annotated>,
3465    ) -> (Vec<ogeom_doc::Callout>, HashMap<u64, usize>) {
3466        // Which callout each annotation plane holds, and the plane's frame.
3467        let mut plane_of: HashMap<u64, Frame> = HashMap::new();
3468        for id in self.ids_with("ANNOTATION_PLANE") {
3469            let Ok(args) = self.args(id, "ANNOTATION_PLANE") else {
3470                continue;
3471            };
3472            let frame = args
3473                .get(2)
3474                .and_then(Arg::reference)
3475                .and_then(|plane| {
3476                    let inner = self.args(plane, "PLANE").ok()?;
3477                    inner.get(1).and_then(Arg::reference)
3478                })
3479                .and_then(|placement| self.frame(placement).ok());
3480            let Some(frame) = frame else { continue };
3481            for element in args.get(3).and_then(Arg::list).unwrap_or(&[]) {
3482                if let Some(callout) = element.reference() {
3483                    plane_of.insert(callout, frame);
3484                }
3485            }
3486        }
3487
3488        // Which semantic annotation each callout draws. The association names
3489        // the annotation's own STEP id, so the link is made by matching that
3490        // against the ids the semantic pass already resolved.
3491        // A callout may be associated more than once (once with the shape
3492        // aspect the annotation is *about*, once with the annotation itself),
3493        // so every association is kept and the first that resolves to a
3494        // semantic annotation is the answer.
3495        let mut draws: HashMap<u64, Vec<u64>> = HashMap::new();
3496        let mut associations = self.ids_with("DRAUGHTING_MODEL_ITEM_ASSOCIATION");
3497        associations.sort_unstable();
3498        for id in associations {
3499            let Ok(args) = self.args(id, "DRAUGHTING_MODEL_ITEM_ASSOCIATION") else {
3500                continue;
3501            };
3502            if let (Some(definition), Some(item)) = (
3503                args.get(2).and_then(Arg::reference),
3504                args.get(4).and_then(Arg::reference),
3505            ) {
3506                draws.entry(item).or_default().push(definition);
3507            }
3508        }
3509
3510        let mut out = Vec::new();
3511        let mut index_of: HashMap<u64, usize> = HashMap::new();
3512        let mut callouts = self.ids_with("DRAUGHTING_CALLOUT");
3513        callouts.sort_unstable();
3514        for id in callouts {
3515            let Ok(args) = self.args(id, "DRAUGHTING_CALLOUT") else {
3516                continue;
3517            };
3518            let name = match args.first() {
3519                Some(Arg::Str(s)) => s.clone(),
3520                _ => String::new(),
3521            };
3522            let mut polylines = Vec::new();
3523            for element in args.get(1).and_then(Arg::list).unwrap_or(&[]).to_vec() {
3524                let Some(occurrence) = element.reference() else {
3525                    continue;
3526                };
3527                polylines.extend(self.annotation_polylines(occurrence));
3528            }
3529            if polylines.is_empty() && !plane_of.contains_key(&id) {
3530                continue;
3531            }
3532            index_of.insert(id, out.len());
3533            out.push(ogeom_doc::Callout {
3534                name,
3535                plane: plane_of.get(&id).copied(),
3536                polylines,
3537                annotates: draws
3538                    .get(&id)
3539                    .cloned()
3540                    .unwrap_or_default()
3541                    .into_iter()
3542                    .find_map(|d| annotation_ids.get(&d).copied()),
3543            });
3544        }
3545        (out, index_of)
3546    }
3547
3548    /// The polylines one annotation occurrence draws.
3549    ///
3550    /// A tessellated occurrence names a curve set, which names a coordinates
3551    /// list and gives each polyline as *one-based* indices into it. That is
3552    /// the whole of the drawn geometry, and it is read as it is written
3553    /// rather than resampled.
3554    fn annotation_polylines(&mut self, occurrence: u64) -> Vec<Vec<Point>> {
3555        let item = {
3556            let Ok(instance) = self.instance(occurrence) else {
3557                return Vec::new();
3558            };
3559            let args = instance
3560                .part("TESSELLATED_ANNOTATION_OCCURRENCE")
3561                .or_else(|| instance.part("ANNOTATION_OCCURRENCE"))
3562                .or_else(|| instance.part("STYLED_ITEM"));
3563            match args.and_then(|a| a.get(2).and_then(Arg::reference)) {
3564                Some(item) => item,
3565                None => return Vec::new(),
3566            }
3567        };
3568        self.tessellated_polylines(item, 0)
3569    }
3570
3571    /// The polylines a tessellated item holds, however deep it nests them.
3572    ///
3573    /// The drawn geometry of one annotation is a *set*: a frame's box, its
3574    /// leader, its text strokes, each its own curve set over its own
3575    /// coordinates list, gathered under one item, which may itself be
3576    /// repositioned by a placement, and that placement is applied here rather
3577    /// than left for a consumer to discover.
3578    fn tessellated_polylines(&mut self, item: u64, depth: usize) -> Vec<Vec<Point>> {
3579        if depth > 4 {
3580            return Vec::new();
3581        }
3582        let (children, curve_set, placement) = {
3583            let Ok(instance) = self.instance(item) else {
3584                return Vec::new();
3585            };
3586            let children: Vec<u64> = instance
3587                .part("TESSELLATED_GEOMETRIC_SET")
3588                .and_then(|args| args.first().and_then(Arg::list))
3589                .map(|list| list.iter().filter_map(Arg::reference).collect())
3590                .unwrap_or_default();
3591            let curve_set = instance.part("TESSELLATED_CURVE_SET").map(<[Arg]>::to_vec);
3592            let placement = instance
3593                .part("REPOSITIONED_TESSELLATED_ITEM")
3594                .and_then(|args| args.first().and_then(Arg::reference));
3595            (children, curve_set, placement)
3596        };
3597
3598        let mut out = Vec::new();
3599        for child in children {
3600            out.extend(self.tessellated_polylines(child, depth + 1));
3601        }
3602        if let Some(args) = curve_set
3603            && let Some(list) = args.get(1).and_then(Arg::reference)
3604        {
3605            let points = self.coordinates_list(list);
3606            for line in args.get(2).and_then(Arg::list).unwrap_or(&[]) {
3607                let Some(indices) = line.list() else { continue };
3608                let mut polyline = Vec::with_capacity(indices.len());
3609                for index in indices {
3610                    // One-based, as the format states them.
3611                    let Some(k) = index.number() else { continue };
3612                    #[expect(
3613                        clippy::cast_possible_truncation,
3614                        clippy::cast_sign_loss,
3615                        reason = "an index into a list the file itself sized"
3616                    )]
3617                    let k = k as usize;
3618                    if k >= 1 && k <= points.len() {
3619                        polyline.push(points[k - 1]);
3620                    }
3621                }
3622                if polyline.len() >= 2 {
3623                    out.push(polyline);
3624                }
3625            }
3626        }
3627        if let Some(placement) = placement
3628            && let Ok(frame) = self.frame(placement)
3629        {
3630            for polyline in &mut out {
3631                for p in polyline.iter_mut() {
3632                    *p = frame.to_world(*p);
3633                }
3634            }
3635        }
3636        out
3637    }
3638
3639    /// A `COORDINATES_LIST`'s points, in the document's own length unit.
3640    fn coordinates_list(&mut self, id: u64) -> Vec<Point> {
3641        let Ok(args) = self.args(id, "COORDINATES_LIST") else {
3642            return Vec::new();
3643        };
3644        let scale = self.report.scale_mm;
3645        args.get(2)
3646            .and_then(Arg::list)
3647            .unwrap_or(&[])
3648            .iter()
3649            .filter_map(|entry| {
3650                let coords = entry.list()?;
3651                let value = |i: usize| coords.get(i).and_then(Arg::number).unwrap_or(0.0) * scale;
3652                Some(Point::new(value(0), value(1), value(2)))
3653            })
3654            .collect()
3655    }
3656
3657    /// Every instance id carrying a part with this keyword.
3658    fn ids_with(&self, keyword: &str) -> Vec<u64> {
3659        self.exchange
3660            .data
3661            .iter()
3662            .filter(|(_, inst)| inst.part(keyword).is_some())
3663            .map(|(id, _)| *id)
3664            .collect()
3665    }
3666
3667    /// One pass over the exchange builds both property-chain indexes:
3668    /// definition → its `PROPERTY_DEFINITION`s (by the definition argument),
3669    /// property → its `SHAPE_DEFINITION_REPRESENTATION`s (by the definition
3670    /// they represent). Ascending ids inside each list, so a lookup visits
3671    /// candidates in the same order the old full scan would have.
3672    fn ensure_property_indexes(&mut self) {
3673        if self.properties_of_definition.is_some() {
3674            return;
3675        }
3676        let mut properties: HashMap<u64, Vec<u64>> = HashMap::new();
3677        let mut sdrs: HashMap<u64, Vec<u64>> = HashMap::new();
3678        let exchange = self.exchange;
3679        for (id, instance) in &exchange.data {
3680            if let Some(args) = instance.part("PROPERTY_DEFINITION") {
3681                // Read for the index is read: the skipped table should not
3682                // claim the reader never looked.
3683                if let Ok(i) = usize::try_from(*id)
3684                    && let Some(slot) = self.visited.get_mut(i)
3685                {
3686                    *slot = true;
3687                }
3688                if let Some(definition) = args.get(2).and_then(Arg::reference) {
3689                    properties.entry(definition).or_default().push(*id);
3690                }
3691            }
3692            if let Some(args) = instance.part("SHAPE_DEFINITION_REPRESENTATION") {
3693                if let Ok(i) = usize::try_from(*id)
3694                    && let Some(slot) = self.visited.get_mut(i)
3695                {
3696                    *slot = true;
3697                }
3698                if let Some(property) = args.first().and_then(Arg::reference) {
3699                    sdrs.entry(property).or_default().push(*id);
3700                }
3701            }
3702        }
3703        for list in properties.values_mut().chain(sdrs.values_mut()) {
3704            list.sort_unstable();
3705        }
3706        self.properties_of_definition = Some(properties);
3707        self.sdrs_of_property = Some(sdrs);
3708    }
3709
3710    /// A measure item's value and kind, scaled into the document's units.
3711    fn measure_value(&mut self, id: u64) -> Option<(f64, ogeom_doc::MeasureKind)> {
3712        let args = {
3713            let instance = self.instance(id).ok()?;
3714            instance.part("MEASURE_WITH_UNIT")?.to_vec()
3715        };
3716        match args.first() {
3717            Some(Arg::Typed(kind, inner)) => {
3718                let value = inner.first().and_then(Arg::number)?;
3719                if kind.contains("ANGLE") {
3720                    Some((value * self.angle_scale, ogeom_doc::MeasureKind::Angle))
3721                } else {
3722                    Some((value * self.report.scale_mm, ogeom_doc::MeasureKind::Length))
3723                }
3724            }
3725            _ => None,
3726        }
3727    }
3728
3729    /// A dimensional characteristic's name, kind and aspects.
3730    ///
3731    /// Sizes apply to one feature; locations (linear or angular) run
3732    /// between two. `ANGULAR_SIZE` and `ANGULAR_LOCATION` are the same
3733    /// shapes with an extra angle-selection argument at the end.
3734    fn dimension_shape(&mut self, dim: u64) -> (String, bool, Vec<u64>) {
3735        let size = {
3736            let Ok(instance) = self.instance(dim) else {
3737                return (String::new(), false, Vec::new());
3738            };
3739            instance
3740                .part("DIMENSIONAL_SIZE")
3741                .or_else(|| instance.part("ANGULAR_SIZE"))
3742                .map(<[Arg]>::to_vec)
3743        };
3744        if let Some(args) = size {
3745            let name = match args.get(1) {
3746                Some(Arg::Str(s)) => s.clone(),
3747                _ => String::new(),
3748            };
3749            let aspects = args.first().and_then(Arg::reference).into_iter().collect();
3750            return (name, false, aspects);
3751        }
3752        let location = {
3753            let Ok(instance) = self.instance(dim) else {
3754                return (String::new(), false, Vec::new());
3755            };
3756            instance
3757                .part("DIMENSIONAL_LOCATION")
3758                .or_else(|| instance.part("ANGULAR_LOCATION"))
3759                .map(<[Arg]>::to_vec)
3760        };
3761        if let Some(args) = location {
3762            let name = match args.first() {
3763                Some(Arg::Str(s)) => s.clone(),
3764                _ => String::new(),
3765            };
3766            let aspects = [args.get(2), args.get(3)]
3767                .into_iter()
3768                .flatten()
3769                .filter_map(Arg::reference)
3770                .collect();
3771            return (name, true, aspects);
3772        }
3773        (String::new(), false, Vec::new())
3774    }
3775
3776    /// The datum letter reachable from a datum reference, depth-bounded: the
3777    /// reference chain runs through compartments and systems, and files
3778    /// arrange it differently.
3779    fn datum_letter(&mut self, id: u64, depth: usize) -> Option<String> {
3780        if depth > 4 {
3781            return None;
3782        }
3783        let (letter, members, refs) = {
3784            let instance = self.instance(id).ok()?;
3785            let letter = instance.part("DATUM").and_then(|args| match args.get(4) {
3786                Some(Arg::Str(s)) if !s.is_empty() => Some(s.clone()),
3787                _ => None,
3788            });
3789            // A compartment or common datum carrying a list of constituent
3790            // references is a composite: every constituent contributes a
3791            // letter, and the letters act as one datum.
3792            let members: Vec<u64> = ["DATUM_REFERENCE_COMPARTMENT", "COMMON_DATUM"]
3793                .iter()
3794                .find_map(|k| instance.part(k))
3795                .into_iter()
3796                .flatten()
3797                .find_map(Arg::list)
3798                .map(|list| list.iter().filter_map(Arg::reference).collect())
3799                .unwrap_or_default();
3800            let mut refs = Vec::new();
3801            for (_, args) in instance.parts() {
3802                collect_refs(args, &mut refs);
3803            }
3804            (letter, members, refs)
3805        };
3806        if let Some(letter) = letter {
3807            return Some(letter);
3808        }
3809        if !members.is_empty() {
3810            let letters: Vec<String> = members
3811                .into_iter()
3812                .filter_map(|r| self.datum_letter(r, depth + 1))
3813                .collect();
3814            if !letters.is_empty() {
3815                return Some(letters.join("-"));
3816            }
3817        }
3818        refs.into_iter()
3819            .find_map(|r| self.datum_letter(r, depth + 1))
3820    }
3821}
3822
3823/// A product definition gathered from the file: its shapes and its usage
3824/// edges, before anything is committed to the document.
3825struct PdEntry {
3826    pd: u64,
3827    name: String,
3828    shapes: Vec<Shape>,
3829    children: Vec<(u64, Transform, Option<String>)>,
3830}
3831
3832/// Every reference in an argument tree, in order.
3833fn collect_refs(args: &[Arg], out: &mut Vec<u64>) {
3834    for arg in args {
3835        match arg {
3836            Arg::Ref(id) => out.push(*id),
3837            Arg::List(inner) | Arg::Typed(_, inner) => collect_refs(inner, out),
3838            _ => {}
3839        }
3840    }
3841}
3842
3843/// The chart coordinates of a point on an analytic surface, by closed-form
3844/// inversion; `None` for surfaces that need iterative projection.
3845/// For a two-wire periodic face: each wire's single closed edge with its
3846/// vertex, empty when the shape is anything else.
3847fn closed_ring_edges(model: &Model, wires: &[Shape]) -> OgeomResult<Vec<(Shape, Shape)>> {
3848    let mut out = Vec::new();
3849    for wire in wires {
3850        let edges = ogeom_topo::explore(
3851            model,
3852            wire,
3853            ogeom_topo::Filter::OfType(ogeom_topo::ShapeType::Edge),
3854        )?;
3855        if edges.len() != 1 {
3856            return Ok(Vec::new());
3857        }
3858        let edge = edges[0].clone();
3859        let Some((a, b)) = ogeom_algo::edge_vertices(model, &edge)? else {
3860            return Ok(Vec::new());
3861        };
3862        if !a.is_same(&b) {
3863            return Ok(Vec::new());
3864        }
3865        out.push((edge, a));
3866    }
3867    Ok(out)
3868}
3869
3870/// Unused-import guard for kinds only touched via traits.
3871#[allow(dead_code)]
3872fn _keep(p: PlanarCurve) -> PlanarCurve {
3873    p
3874}
3875
3876/// The window of `curve` between the feet of two vertices that stand on it
3877/// short of its ends: both within a hair of the curve, in the curve's own
3878/// order, and not the whole curve. `None` where the vertices are off the
3879/// curve, reversed against it, or already at its ends.
3880fn window_between_feet(
3881    curve: &Curve,
3882    start: Point,
3883    end: Point,
3884    tol: Tolerances,
3885) -> Option<(f64, f64)> {
3886    let (lo, hi) = curve.domain();
3887    let samples = match curve {
3888        Curve::BSpline(spline) => (spline.control_points().len() * 8).max(64),
3889        _ => 64,
3890    };
3891    let foot = |p: Point| -> Option<f64> {
3892        let found = project_on_curve(curve, p, samples, tol).ok()?;
3893        (found.distance <= tol.confusion() * 1e4).then_some(found.parameter)
3894    };
3895    let (a, b) = (foot(start)?, foot(end)?);
3896    let slack = tol.parametric().max((hi - lo) * 1e-9);
3897    if b <= a + slack {
3898        return None;
3899    }
3900    if (a - lo).abs() <= slack && (b - hi).abs() <= slack {
3901        return None;
3902    }
3903    Some((a, b))
3904}
3905
3906/// The knots a STEP B-spline form implies when it states none: a Bézier
3907/// form's pieces joined at whole numbers, a uniform form's knots one apart
3908/// from `-degree`, a quasi-uniform form's the same clamped at both ends.
3909fn implied_knots(form: &str, degree: usize, count: usize) -> Vec<f64> {
3910    let p = degree.max(1);
3911    #[allow(clippy::cast_precision_loss)]
3912    let at = |i: usize| i as f64;
3913    if form.starts_with("BEZIER") {
3914        let pieces = (count.saturating_sub(1) / p).max(1);
3915        let mut out = vec![0.0; p + 1];
3916        for s in 1..pieces {
3917            out.extend(std::iter::repeat_n(at(s), p));
3918        }
3919        out.extend(std::iter::repeat_n(at(pieces), p + 1));
3920        out
3921    } else if form.starts_with("QUASI_UNIFORM") {
3922        let interior = count.saturating_sub(p + 1);
3923        let mut out = vec![0.0; p + 1];
3924        out.extend((1..=interior).map(at));
3925        out.extend(std::iter::repeat_n(at(interior + 1), p + 1));
3926        out
3927    } else {
3928        #[allow(clippy::cast_precision_loss)]
3929        (0..count + p + 1).map(|i| i as f64 - p as f64).collect()
3930    }
3931}
3932
3933/// Two splines joined end to start: the lower degree raised to the
3934/// higher, the second's weights scaled to meet the first's at the join (a
3935/// rational curve is unchanged by scaling all its weights), and the join
3936/// knot left at full multiplicity.
3937fn join_splines(
3938    held: (KnotVector, Vec<Weighted<Point>>),
3939    next: &BSplineCurve,
3940    tol: Tolerances,
3941) -> OgeomResult<(KnotVector, Vec<Weighted<Point>>)> {
3942    let (mut knots, mut control) = held;
3943    let mut next = next.clone();
3944    while next.degree() < knots.degree() {
3945        next = next.elevated(tol)?;
3946    }
3947    while knots.degree() < next.degree() {
3948        (knots, control) = ogeom_math::bspline::elevate_degree(&knots, &control, tol)?;
3949    }
3950    let (Some(end), Some(start)) = (control.last(), next.control_points().first()) else {
3951        ogeom_bail!(Construction, "a composite segment has no control points");
3952    };
3953    let factor = end.weight / start.weight;
3954    let scaled: Vec<Weighted<Point>> = next
3955        .control_points()
3956        .iter()
3957        .map(|w| Weighted {
3958            scaled: w.scaled.scale(factor),
3959            weight: w.weight * factor,
3960        })
3961        .collect();
3962    ogeom_math::bspline::join(&(knots, control), &(next.knots().clone(), scaled))
3963}
3964
3965/// One patch of a composite surface as a tensor spline: its knots each way,
3966/// on the unit interval, and its net indexed `[u][v]`.
3967#[derive(Debug, Clone)]
3968struct Patch {
3969    u: KnotVector,
3970    v: KnotVector,
3971    net: Vec<Vec<Weighted<Point>>>,
3972}
3973
3974impl Patch {
3975    fn of(surface: &ogeom_geom::BSplineSurface) -> OgeomResult<Self> {
3976        let grid = surface.grid();
3977        let (nu, nv) = (grid.u_count(), grid.v_count());
3978        let points = grid.points();
3979        let net = (0..nu)
3980            .map(|i| (0..nv).map(|j| points[i * nv + j]).collect())
3981            .collect();
3982        Ok(Self {
3983            u: surface.u_knots().reparameterized(0.0, 1.0)?,
3984            v: surface.v_knots().reparameterized(0.0, 1.0)?,
3985            net,
3986        })
3987    }
3988
3989    fn transposed(self) -> Self {
3990        let (nu, nv) = (self.net.len(), self.net[0].len());
3991        let net = (0..nv)
3992            .map(|j| (0..nu).map(|i| self.net[i][j]).collect())
3993            .collect();
3994        Self {
3995            u: self.v,
3996            v: self.u,
3997            net,
3998        }
3999    }
4000
4001    fn reversed_u(mut self) -> Self {
4002        self.net.reverse();
4003        self.u = self.u.reversed();
4004        self
4005    }
4006
4007    /// Apply a curve operation along `u` to every column of the net.
4008    fn along_u(
4009        self,
4010        op: impl Fn(&KnotVector, &[Weighted<Point>]) -> OgeomResult<(KnotVector, Vec<Weighted<Point>>)>,
4011    ) -> OgeomResult<Self> {
4012        let nv = self.net[0].len();
4013        let mut knots = self.u.clone();
4014        let mut columns = Vec::with_capacity(nv);
4015        for j in 0..nv {
4016            let column: Vec<Weighted<Point>> = self.net.iter().map(|row| row[j]).collect();
4017            let (k, c) = op(&self.u, &column)?;
4018            knots = k;
4019            columns.push(c);
4020        }
4021        let nu = columns[0].len();
4022        let net = (0..nu)
4023            .map(|i| (0..nv).map(|j| columns[j][i]).collect())
4024            .collect();
4025        Ok(Self {
4026            u: knots,
4027            v: self.v,
4028            net,
4029        })
4030    }
4031}
4032
4033/// A grid of patches (`grid[i][j]`, `i` along `u`) joined into one surface.
4034fn join_patches(mut grid: Vec<Vec<Patch>>, tol: Tolerances) -> OgeomResult<SurfaceGeometry> {
4035    let (m, n) = (grid.len(), grid[0].len());
4036    // Degrees raised to the grid's highest each way.
4037    let pu = grid
4038        .iter()
4039        .flatten()
4040        .map(|p| p.u.degree())
4041        .max()
4042        .unwrap_or(1);
4043    let pv = grid
4044        .iter()
4045        .flatten()
4046        .map(|p| p.v.degree())
4047        .max()
4048        .unwrap_or(1);
4049    for patch in grid.iter_mut().flatten() {
4050        let mut p = patch.clone();
4051        while p.u.degree() < pu {
4052            p = p.along_u(|k, c| ogeom_math::bspline::elevate_degree(k, c, tol))?;
4053        }
4054        p = p.transposed();
4055        while p.u.degree() < pv {
4056            p = p.along_u(|k, c| ogeom_math::bspline::elevate_degree(k, c, tol))?;
4057        }
4058        *patch = p.transposed();
4059    }
4060    // Knots unified: along u within each grid row of patches sharing an
4061    // index `i` (their u knots must agree to share v boundaries), along v
4062    // within each index `j`.
4063    let unify = |patches: Vec<Patch>, along_v: bool| -> OgeomResult<Vec<Patch>> {
4064        let turned: Vec<Patch> = if along_v {
4065            patches.into_iter().map(Patch::transposed).collect()
4066        } else {
4067            patches
4068        };
4069        let mut wanted: Vec<(f64, usize)> = Vec::new();
4070        for p in &turned {
4071            for (value, count) in p.u.distinct() {
4072                if value <= 1e-12 || value >= 1.0 - 1e-12 {
4073                    continue;
4074                }
4075                match wanted.iter_mut().find(|(v, _)| (v - value).abs() <= 1e-12) {
4076                    Some(slot) => slot.1 = slot.1.max(count),
4077                    None => wanted.push((value, count)),
4078                }
4079            }
4080        }
4081        let mut out = Vec::with_capacity(turned.len());
4082        for mut p in turned {
4083            for &(value, count) in &wanted {
4084                let have = p.u.multiplicity_of(value);
4085                if have < count {
4086                    p = p.along_u(|k, c| {
4087                        ogeom_math::bspline::insert_knot(k, c, value, count - have, tol)
4088                    })?;
4089                }
4090            }
4091            out.push(if along_v { p.transposed() } else { p });
4092        }
4093        Ok(out)
4094    };
4095    for row in &mut grid {
4096        *row = unify(std::mem::take(row), true)?;
4097    }
4098    for j in 0..n {
4099        let column: Vec<Patch> = grid.iter().map(|row| row[j].clone()).collect();
4100        for (row, p) in grid.iter_mut().zip(unify(column, false)?) {
4101            row[j] = p;
4102        }
4103    }
4104    // Joined: shared boundaries once, each join a knot of full multiplicity.
4105    let near = |a: &Weighted<Point>, b: &Weighted<Point>| {
4106        a.point().distance(b.point()) <= tol.confusion() * 100.0
4107            && (a.weight - b.weight).abs() <= 1e-9 * a.weight.abs().max(1.0)
4108    };
4109    let mut rows: Vec<Vec<Weighted<Point>>> = Vec::new();
4110    for (i, grid_row) in grid.iter().enumerate() {
4111        let nu = grid_row[0].net.len();
4112        for a in 0..nu {
4113            if i > 0 && a == 0 {
4114                // The row this patch shares with the one before it.
4115                let prev = rows.len() - 1;
4116                let mut shared = 0;
4117                for (j, patch) in grid_row.iter().enumerate() {
4118                    for (b, cell) in patch.net[0].iter().enumerate() {
4119                        if j > 0 && b == 0 {
4120                            continue;
4121                        }
4122                        if !near(&rows[prev][shared], cell) {
4123                            ogeom_bail!(Construction, "neighbouring patches part along u");
4124                        }
4125                        shared += 1;
4126                    }
4127                }
4128                continue;
4129            }
4130            let mut row = Vec::new();
4131            for (j, patch) in grid_row.iter().enumerate() {
4132                for (b, cell) in patch.net[a].iter().enumerate() {
4133                    if j > 0 && b == 0 {
4134                        if !near(row.last().unwrap_or(cell), cell) {
4135                            ogeom_bail!(Construction, "neighbouring patches part along v");
4136                        }
4137                        continue;
4138                    }
4139                    row.push(*cell);
4140                }
4141            }
4142            rows.push(row);
4143        }
4144    }
4145    // Each patch's knots shifted to its own unit of the whole; at a join
4146    // one copy of the shared end goes, leaving the multiplicity at the
4147    // degree, and the next patch's run follows past its own clamped start.
4148    let joined_knots = |parts: Vec<&KnotVector>, degree: usize| -> OgeomResult<KnotVector> {
4149        let mut out: Vec<f64> = Vec::new();
4150        for (k, knots) in parts.iter().enumerate() {
4151            #[allow(clippy::cast_precision_loss)]
4152            let shift = k as f64;
4153            let values: Vec<f64> = knots.knots().iter().map(|x| x + shift).collect();
4154            if k == 0 {
4155                out.extend(values);
4156            } else {
4157                out.pop();
4158                out.extend_from_slice(&values[degree + 1..]);
4159            }
4160        }
4161        KnotVector::new(out, degree)
4162    };
4163    let u = joined_knots((0..m).map(|i| &grid[i][0].u).collect(), pu)?;
4164    let v = joined_knots((0..n).map(|j| &grid[0][j].v).collect(), pv)?;
4165    let (nu, nv) = (rows.len(), rows[0].len());
4166    let cells: Vec<Weighted<Point>> = rows.into_iter().flatten().collect();
4167    Ok(
4168        ogeom_geom::BSplineSurface::rational(u, v, ogeom_math::ControlGrid::new(cells, nu, nv)?)?
4169            .into(),
4170    )
4171}
4172
4173#[cfg(test)]
4174#[allow(clippy::unwrap_used)]
4175mod tests {
4176    use super::*;
4177
4178    const T: Tolerances = Tolerances::millimetres();
4179
4180    /// An edge's window between the feet of vertices standing on the
4181    /// curve short of its ends: taken in the curve's order, refused where
4182    /// the vertices are the ends already, run against the curve, or off it.
4183    #[test]
4184    fn an_edges_window_is_taken_between_its_vertices_feet() {
4185        let spline: Curve = BSplineCurve::new(
4186            KnotVector::clamped_uniform(3, 6).unwrap(),
4187            vec![
4188                Point::new(0.0, 0.0, 0.0),
4189                Point::new(1.0, 2.0, 0.5),
4190                Point::new(2.5, 1.0, -0.5),
4191                Point::new(4.0, 3.0, 1.0),
4192                Point::new(5.0, 0.5, 0.0),
4193                Point::new(6.0, 2.0, 2.0),
4194            ],
4195            T,
4196        )
4197        .unwrap()
4198        .into();
4199        let at = |t: f64| spline.point_at(t, T).unwrap();
4200        let (a, b) = window_between_feet(&spline, at(0.3), at(0.7), T).unwrap();
4201        assert!(
4202            (a - 0.3).abs() < 1e-9 && (b - 0.7).abs() < 1e-9,
4203            "{a} .. {b}"
4204        );
4205        let (a, b) = window_between_feet(&spline, at(0.0), at(0.7), T).unwrap();
4206        assert!(a.abs() < 1e-9 && (b - 0.7).abs() < 1e-9, "{a} .. {b}");
4207        assert!(window_between_feet(&spline, at(0.0), at(1.0), T).is_none());
4208        assert!(window_between_feet(&spline, at(0.7), at(0.3), T).is_none());
4209        let off = at(0.3) + Vector::new(0.0, 0.0, 0.5);
4210        assert!(window_between_feet(&spline, off, at(0.7), T).is_none());
4211    }
4212}