Skip to main content

ogeom_io/iges/
read.rs

1//! From an IGES deck to a living model.
2//!
3//! Two kinds of file arrive under one extension. A *solid* file carries
4//! manifold solid B-rep objects (entity 186 over shells, faces, loops, edge
5//! lists and vertex lists) and reads bottom-up the way the STEP reader does,
6//! sharing what the file shares. A *surface* file, the older and far more
7//! common kind, is a loose collection of trimmed surfaces; those become
8//! faces, the faces are sewn, and a shell that closes becomes a solid. Both
9//! kinds re-derive edge ranges on this kernel's own parameterizations from
10//! the endpoint geometry, because a 1980s file's parameterizations are its
11//! own business.
12//!
13//! What the reader does not understand it *counts*: every entity never
14//! visited lands in the report's skipped table under its type number, and
15//! every compromise is a warning naming the directory entry. Refusals are by
16//! name: a conic form this reader does not translate says which form and
17//! where, and points at the capability ledger's `io.iges` row for the whole
18//! picture.
19
20use super::parse::{Entity, File, Value};
21use ogeom_algo::{make_edge_between, make_solid, make_vertex, sew};
22use ogeom_core::FastMap;
23use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
24use ogeom_geom::Curve3d as _;
25use ogeom_geom::Transformable as _;
26use ogeom_geom::{
27    BSplineCurve, BSplineSurface, CircleCurve, ConeSurface, Curve, CylinderSurface, EllipseCurve,
28    ExtrusionSurface, HyperbolaCurve, LineCurve, OffsetCurve, OffsetSurface, ParabolaCurve,
29    PlaneSurface, RevolutionSurface, SphereSurface, SurfaceGeometry, TorusSurface, TrimmedCurve,
30};
31use ogeom_math::{
32    Circle, Cone, ControlGrid, Cylinder, Direction, Ellipse, Frame, Hyperbola, KnotVector, Matrix3,
33    Parabola, Plane, Point, Sphere, Torus, Transform, Vector, Weighted,
34};
35use ogeom_topo::{Model, Shape};
36use std::collections::BTreeMap;
37
38/// How far an unbounded plane or quadric extends past anything the file uses
39/// (the same convention the STEP reader states: a face's trim is its wires,
40/// and the surface's domain is only a parameter window).
41const SURFACE_EXTENT: f64 = 1e5;
42
43/// Whether an entity is a bounded plane (108, form 1) that is geometry,
44/// the plane within its bounding curve. A negative bounded plane (form -1)
45/// is a hole only a parent entity could cut, and a view's clipping plane
46/// is annotation (use flag 01); neither stands as a face.
47fn is_bounded_plane(entity: &Entity) -> bool {
48    entity.kind == 108 && entity.form == 1 && (entity.status / 100) % 100 == 0
49}
50
51/// The constructive solid entities: the primitives, the solids of revolution
52/// and extrusion, the ellipsoid, the boolean tree, the solid assembly and
53/// the solid instance.
54/// The format's parameter at the start of a conic arc (104) that is an
55/// ellipse with its axes along its definition plane's: the angle `t` of the
56/// start `(cx + a cos t, cy + b sin t)`, in [0, 2 pi). `None` for a conic
57/// whose axes turn, or that is not an ellipse, for which the format states
58/// no parameter this reader carries.
59fn ellipse_start_angle(conic: &Entity) -> Option<f64> {
60    let [a, b, c, d, e, f] = [0, 1, 2, 3, 4, 5].map(|i| conic.at(i).real());
61    if b != 0.0 || a == 0.0 || c == 0.0 || (a > 0.0) != (c > 0.0) {
62        return None;
63    }
64    let (cx, cy) = (-d / (2.0 * a), -e / (2.0 * c));
65    let rhs = a * cx * cx + c * cy * cy - f;
66    let (ra2, rb2) = (rhs / a, rhs / c);
67    if ra2 <= 0.0 || rb2 <= 0.0 {
68        return None;
69    }
70    let (sx, sy) = (conic.at(7).real(), conic.at(8).real());
71    Some(
72        ((sy - cy) / rb2.sqrt())
73            .atan2((sx - cx) / ra2.sqrt())
74            .rem_euclid(core::f64::consts::TAU),
75    )
76}
77
78/// The surfaces that bound themselves, and so stand as faces with no trim:
79/// parametric spline, ruled, revolved, tabulated, B-spline and offset
80/// surfaces (an offset only of one of the others).
81const UNTRIMMED_KINDS: [i64; 6] = [114, 118, 120, 122, 128, 140];
82
83const CSG_KINDS: [i64; 12] = [150, 152, 154, 156, 158, 160, 162, 164, 168, 180, 184, 430];
84
85/// The curves that stand as wireframe: circular arc, composite curve, conic
86/// arc, copious data, line, parametric spline, rational B-spline and offset
87/// curve.
88const WIRE_KINDS: [i64; 8] = [100, 102, 104, 106, 110, 112, 126, 130];
89
90/// Whether an entity is a curve that stands as wireframe: one of
91/// [`WIRE_KINDS`], copious data only where it draws a path (forms 11 to 13
92/// and 63), not a point set or a drafting form.
93fn is_wire(entity: &Entity) -> bool {
94    WIRE_KINDS.contains(&entity.kind)
95        && (entity.kind != 106 || matches!(entity.form, 11 | 12 | 13 | 63))
96}
97
98/// Curve segments moved by `motion`.
99fn moved_segments(
100    segments: Vec<(Curve, (f64, f64))>,
101    motion: &Transform,
102    tol: Tolerances,
103) -> OgeomResult<Vec<(Curve, (f64, f64))>> {
104    if *motion == Transform::IDENTITY {
105        return Ok(segments);
106    }
107    segments
108        .into_iter()
109        .map(|(curve, range)| Ok((curve.transformed(motion, tol)?, range)))
110        .collect()
111}
112
113/// Whether a parent's matrix carries a child it points at (IGES 5.3,
114/// section 3.2.3): the child lies in the parent's definition space and
115/// moves with it, unless its subordinate switch says it is only logically
116/// dependent (02), which leaves it where its own matrix puts it.
117fn carried(child: &Entity) -> bool {
118    (child.status / 10_000) % 100 != 2
119}
120
121/// The subfigure instances: singular (408), rectangular array (412),
122/// circular array (414) and network (420).
123const INSTANCE_KINDS: [i64; 4] = [408, 412, 414, 420];
124
125/// How many shapes one instance may place, counting each position's copy
126/// of every shape its base holds. An array is a pattern of holes or
127/// fasteners; a million placed shapes is past any real file, and arrays
128/// nested in arrays multiply, so a file past it is refused as hostile
129/// before it exhausts memory.
130const MOST_PLACED: usize = 1 << 20;
131
132/// What an import brought in, and what it left behind.
133#[derive(Debug, Default)]
134pub struct IgesReport {
135    /// Millimetres per file unit, as the global section states it.
136    pub scale_mm: f64,
137    /// Entity types the reader never visited, with counts, keyed as
138    /// `"type NNN"` or `"type NNN form F"`. Annotation and drafting land
139    /// here by design; geometry landing here is a gap worth reading about.
140    pub skipped: BTreeMap<String, usize>,
141    /// Everything that imported less than perfectly, one line each.
142    pub warnings: Vec<String>,
143}
144
145/// A read IGES file: the document, the shapes found, and the report.
146#[derive(Debug)]
147pub struct IgesImport {
148    /// The document everything was built into.
149    pub document: ogeom_doc::Document,
150    /// One shape per manifold solid, then one per surface group that sewed
151    /// closed.
152    pub solids: Vec<Shape>,
153    /// Sewn shells and loose faces that do not enclose a volume.
154    pub sheets: Vec<Shape>,
155    /// Wireframe: one wire per independent curve, and one per curve in
156    /// each subfigure placement. Wires live in the document's model but
157    /// are no part of it, so a document read here writes wherever it did.
158    pub wires: Vec<Shape>,
159    /// Stand-alone points: one vertex per independent point (116) that is
160    /// geometry, and one per point in each subfigure placement. They live
161    /// in the document's model as the wires do.
162    pub points: Vec<Shape>,
163    /// What happened along the way.
164    pub report: IgesReport,
165}
166
167/// What a subfigure definition, an array's base or any member of either
168/// holds at its own coordinates.
169#[derive(Clone, Default)]
170struct Content {
171    solids: Vec<Shape>,
172    /// Faces, sewn once placed.
173    faces: Vec<Shape>,
174    wires: Vec<Shape>,
175    points: Vec<Shape>,
176}
177
178impl Content {
179    fn moved(&self, location: &ogeom_topo::Location) -> Self {
180        let moved = |shapes: &[Shape]| shapes.iter().map(|x| x.moved(location)).collect();
181        Self {
182            solids: moved(&self.solids),
183            faces: moved(&self.faces),
184            wires: moved(&self.wires),
185            points: moved(&self.points),
186        }
187    }
188
189    fn len(&self) -> usize {
190        self.solids.len() + self.faces.len() + self.wires.len() + self.points.len()
191    }
192
193    fn extend(&mut self, other: Self) {
194        self.solids.extend(other.solids);
195        self.faces.extend(other.faces);
196        self.wires.extend(other.wires);
197        self.points.extend(other.points);
198    }
199}
200
201/// What the independent subfigure instances placed.
202#[derive(Default)]
203struct Placed {
204    solids: Vec<Shape>,
205    /// Each placement's faces, sewn, whose shells did not close.
206    sheets: Vec<Shape>,
207    wires: Vec<Shape>,
208    points: Vec<Shape>,
209    /// Which instance placed each solid and face, for levels and groups.
210    from: Vec<(i64, Shape)>,
211}
212
213/// An array's number of positions, refused where it is not positive or
214/// is past [`MOST_PLACED`].
215fn array_count(de: i64, count: Option<i64>) -> OgeomResult<usize> {
216    match count.and_then(|c| usize::try_from(c).ok()) {
217        Some(c) if (1..=MOST_PLACED).contains(&c) => Ok(c),
218        _ => ogeom_bail!(
219            Construction,
220            "D{de}: an array of {count:?} positions; it needs 1 to {MOST_PLACED}"
221        ),
222    }
223}
224
225/// The 1-based positions of `count` an array keeps: all of them when its
226/// list count (at parameter `at`) is zero; otherwise those its list names
227/// when the flag after the count says DO (0), and all but those when it
228/// says DON'T (1). Numbers outside the array name no position.
229fn kept_positions(array: &Entity, at: usize, count: usize) -> Vec<usize> {
230    let listed = array.count(at);
231    if listed == 0 {
232        return (1..=count).collect();
233    }
234    let named: std::collections::BTreeSet<usize> = (0..listed)
235        .filter_map(|i| usize::try_from(array.at(at + 2 + i).int()).ok())
236        .filter(|k| (1..=count).contains(k))
237        .collect();
238    if array.at(at + 1).int() == 1 {
239        (1..=count).filter(|k| !named.contains(k)).collect()
240    } else {
241        named.into_iter().collect()
242    }
243}
244
245/// The pieces an edge-list entry resolves to.
246type BuiltEdge = (Shape, Curve, (f64, f64));
247
248/// The files a deck's external references (416) name, read from the
249/// directory the deck was read from: each by the file name it is referred
250/// to by, or why it could not be read.
251struct Library {
252    /// Where the files are looked for; `None` for a deck handed in as text.
253    dir: Option<std::path::PathBuf>,
254    files: BTreeMap<String, Result<File, String>>,
255}
256
257/// The library of a deck handed in as text, which looks for no files.
258static NO_LIBRARY: Library = Library {
259    dir: None,
260    files: BTreeMap::new(),
261};
262
263impl Library {
264    /// Every file `root`'s external references name, and every file those
265    /// name in turn, each read once.
266    fn gather(dir: &std::path::Path, root: &File) -> Self {
267        let mut library = Self {
268            dir: Some(dir.to_path_buf()),
269            files: BTreeMap::new(),
270        };
271        let mut wanted = referenced_files(root);
272        while let Some(name) = wanted.pop() {
273            if library.files.contains_key(&name) {
274                continue;
275            }
276            let read = library.read(&name);
277            if let Ok(file) = &read {
278                wanted.extend(referenced_files(file));
279            }
280            library.files.insert(name, read);
281        }
282        library
283    }
284
285    /// The file named `name` beside the deck, parsed, and refused where
286    /// its global section names it otherwise: the format identifies a
287    /// referenced file by the name it gives itself there (IGES 5.3,
288    /// section 3.6.4).
289    fn read(&self, name: &str) -> Result<File, String> {
290        let Some(dir) = &self.dir else {
291            return Err("the deck was read as text, with no directory to look in; \
292                 read_iges_file looks beside the file"
293                .to_owned());
294        };
295        let path = dir.join(name);
296        if !path.is_file() {
297            return Err(format!("{} is not there", path.display()));
298        }
299        let text = std::fs::read_to_string(&path)
300            .map_err(|e| format!("{} could not be read: {e}", path.display()))?;
301        let file = super::parse::parse(&text).map_err(|e| format!("{}: {e}", path.display()))?;
302        let own = file
303            .global
304            .get(3)
305            .and_then(Value::text)
306            .map_or("", file_name);
307        if own != name {
308            return Err(format!(
309                "{} names itself \"{own}\" in its global section, not \"{name}\"",
310                path.display()
311            ));
312        }
313        Ok(file)
314    }
315
316    /// The file an external reference names by `id`.
317    fn file(&self, id: &str) -> Result<&File, String> {
318        let name = file_name(id);
319        match self.files.get(name) {
320            Some(Ok(file)) => Ok(file),
321            Some(Err(why)) => Err(why.clone()),
322            None => Err(self
323                .read(name)
324                .err()
325                .unwrap_or_else(|| format!("\"{name}\" was not gathered with the deck"))),
326        }
327    }
328}
329
330/// The last component of a file identifier, which may carry the sending
331/// system's directories in its own spelling (`/`, `\`, a drive's `:` or
332/// a bracketed directory's `]`): the name the file is looked for by,
333/// beside the deck that refers to it.
334fn file_name(id: &str) -> &str {
335    id.rsplit(['/', '\\', ':', ']']).next().unwrap_or(id).trim()
336}
337
338/// The names of the files `file`'s external references (416) to a
339/// definition or a whole file (forms 0 and 1) refer to.
340fn referenced_files(file: &File) -> Vec<String> {
341    file.entities
342        .values()
343        .filter(|e| e.kind == 416 && matches!(e.form, 0 | 1))
344        .filter_map(|e| e.at(0).text())
345        .map(|id| file_name(id).to_owned())
346        .filter(|name| !name.is_empty())
347        .collect()
348}
349
350/// Read an IGES file's geometry.
351///
352/// An external reference (416) needs the file it names, which a deck
353/// handed in as text cannot reach; [`read_iges_file`] reads a deck from
354/// disk and finds them beside it. Here every instance of one is refused
355/// by name.
356///
357/// # Errors
358///
359/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
360/// deck does not parse, its units are unreadable, or it contains nothing this
361/// reader translates into shapes. Individual entities that fail to translate
362/// become warnings and skipped counts rather than errors; the report says
363/// exactly what was compromised.
364pub fn read_iges(text: &str, tol: Tolerances) -> OgeomResult<IgesImport> {
365    let file = super::parse::parse(text)?;
366    read_parsed(&file, &NO_LIBRARY, tol)
367}
368
369/// Read an IGES file's geometry from disk, as [`read_iges`] reads a deck,
370/// with its external references (416) resolved against the files in the
371/// same directory.
372///
373/// A reference names its file by the identifier the file gives itself as
374/// its fourth global parameter (IGES 5.3, section 3.6.4); the file is
375/// looked for under that identifier's last component, in the directory
376/// `path` is in, and nowhere else. A reference to a definition (form 0)
377/// takes the entity the referenced file's index (402, form 12) lists under
378/// its symbolic name, and a reference to a whole file (form 1) takes every
379/// shape that file reads as, both at that file's own coordinates and units,
380/// for the instance that names the reference to place. Files refer on to
381/// files the same way. A missing file, one that names itself otherwise,
382/// a name its index does not list, a logical reference (form 2) and a
383/// reference to a definition the receiving system keeps natively (forms 3
384/// and 4) are each refused by name, and the instance placing it with
385/// them.
386///
387/// # Errors
388///
389/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
390/// `path` cannot be read as text, and as [`read_iges`].
391pub fn read_iges_file(path: &std::path::Path, tol: Tolerances) -> OgeomResult<IgesImport> {
392    let text = match std::fs::read_to_string(path) {
393        Ok(text) => text,
394        Err(e) => ogeom_bail!(Construction, "{} could not be read: {e}", path.display()),
395    };
396    let file = super::parse::parse(&text)?;
397    let dir = path.parent().unwrap_or_else(|| std::path::Path::new(""));
398    let library = Library::gather(dir, &file);
399    read_parsed(&file, &library, tol)
400}
401
402/// [`read_iges`] of a parsed deck, its external references resolved in
403/// `library`.
404fn read_parsed(file: &File, library: &Library, tol: Tolerances) -> OgeomResult<IgesImport> {
405    let Some(scale_mm) = file.scale_mm() else {
406        ogeom_bail!(
407            Construction,
408            "IGES global section names units this reader cannot convert to millimetres"
409        );
410    };
411    let mut reader = Reader::new(file, Model::new(), scale_mm, library, tol);
412    let on_sheets = on_sheets(file);
413    let construction_des = construction_entities(file, &on_sheets);
414
415    // Solids first: every 186, in directory order.
416    let mut solids: Vec<(i64, Shape)> = Vec::new();
417    let solid_des: Vec<i64> = file
418        .entities
419        .iter()
420        .filter(|(_, e)| e.kind == 186)
421        .map(|(de, _)| *de)
422        .collect();
423    let total = solid_des.len() as u64;
424    for (done, de) in solid_des.into_iter().enumerate() {
425        ogeom_core::progress::checkpoint()?;
426        ogeom_core::progress::stage_at("iges: solid", done as u64 + 1, total);
427        match reader.manifold_solid(de) {
428            Ok(solid) => solids.push((de, solid)),
429            Err(e) => reader
430                .report
431                .warnings
432                .push(format!("D{de}: manifold solid failed to build: {e}")),
433        }
434    }
435
436    // Then the surface file: every *independent* trimmed or bounded surface
437    // becomes a face; the faces sew; closed shells become solids. Subordinate
438    // entities belong to something else and are not top-level geometry; the
439    // subordinate switch is the second two-digit field of the status word.
440    let mut faces = Vec::new();
441    // Which directory entry built which shape, for levels and groups.
442    let mut built_from: Vec<(i64, Shape)> = solids.clone();
443    let face_des: Vec<i64> = file
444        .entities
445        .iter()
446        .filter(|(de, e)| {
447            matches!(e.kind, 143 | 144)
448                && (e.status / 10_000) % 100 == 0
449                && !reader.visited.contains_key(de)
450                && !construction_des.contains(de)
451        })
452        .map(|(de, _)| *de)
453        .collect();
454    let total = face_des.len() as u64;
455    reader.trim_poles = reader.poles_of_trims(&face_des);
456    for (done, de) in face_des.into_iter().enumerate() {
457        ogeom_core::progress::checkpoint()?;
458        ogeom_core::progress::stage_at("iges: face", done as u64 + 1, total);
459        match reader.face(de) {
460            Ok(face) => {
461                built_from.push((de, face.clone()));
462                faces.push(face);
463            }
464            Err(e) => reader
465                .report
466                .warnings
467                .push(format!("D{de}: trimmed surface failed to build: {e}")),
468        }
469    }
470    // Untrimmed surfaces: every independent bounded surface no trimmed
471    // surface or solid took is the face its own bounds enclose, and every
472    // independent bounded plane the face its curve encloses, sewn with the
473    // rest.
474    let bare_des: Vec<i64> = file
475        .entities
476        .iter()
477        .filter(|(de, e)| {
478            (UNTRIMMED_KINDS.contains(&e.kind) || is_bounded_plane(e))
479                && (e.status / 10_000) % 100 == 0
480                && !reader.visited.contains_key(de)
481                && !construction_des.contains(de)
482        })
483        .map(|(de, _)| *de)
484        .collect();
485    for de in bare_des {
486        match reader.untrimmed_face(de) {
487            Ok(face) => {
488                built_from.push((de, face.clone()));
489                faces.push(face);
490            }
491            Err(e) => reader
492                .report
493                .warnings
494                .push(format!("D{de}: untrimmed surface failed to build: {e}")),
495        }
496    }
497    let mut sheets = Vec::new();
498    if !faces.is_empty() {
499        let sewn = sew(&mut reader.model, &faces, tol)?;
500        for shell in &sewn.shells {
501            // A face's seam sewn from two edges is one edge holding its
502            // column as plain pcurves; it is made the seam it is.
503            ogeom_algo::join_seam_columns(&mut reader.model, shell, tol)?;
504            if ogeom_algo::is_shell_closed(&reader.model, shell)? {
505                let solid = make_solid(&mut reader.model, std::slice::from_ref(shell))?.shape;
506                solids.push((0, solid));
507            } else {
508                sheets.push(shell.clone());
509            }
510        }
511    }
512
513    // Constructive solids: every independent primitive, boolean tree,
514    // assembly and instance.
515    let csg_des: Vec<i64> = file
516        .entities
517        .iter()
518        .filter(|(de, e)| {
519            CSG_KINDS.contains(&e.kind)
520                && (e.status / 10_000) % 100 == 0
521                && !reader.visited.contains_key(de)
522        })
523        .map(|(de, _)| *de)
524        .collect();
525    for de in csg_des {
526        match reader.csg_solids(de) {
527            Ok(built) => {
528                for solid in built {
529                    built_from.push((de, solid.clone()));
530                    solids.push((de, solid));
531                }
532            }
533            Err(e) => reader
534                .report
535                .warnings
536                .push(format!("D{de}: constructive solid failed to build: {e}")),
537        }
538    }
539
540    // Subfigure instances: each independent 408 places its definition, and
541    // each 412 and 414 its base at every position its pattern keeps, built
542    // once and shared by every placement.
543    let placed = reader.subfigure_instances(tol)?;
544    solids.extend(placed.solids.into_iter().map(|s| (0, s)));
545    sheets.extend(placed.sheets);
546    built_from.extend(placed.from);
547    let mut wires = placed.wires;
548    let mut points = placed.points;
549
550    // Wireframe: every independent curve that is geometry (use flag 00, so
551    // not a parameter-space curve, construction or annotation), that no
552    // drawing lists and that nothing above took, as a wire.
553    let curve_des: Vec<i64> = file
554        .entities
555        .iter()
556        .filter(|(de, e)| {
557            is_wire(e)
558                && (e.status / 10_000) % 100 == 0
559                && (e.status / 100) % 100 == 0
560                && !on_sheets.contains(de)
561                && !reader.visited.contains_key(de)
562        })
563        .map(|(de, _)| *de)
564        .collect();
565    for de in curve_des {
566        match reader.wireframe(de) {
567            Ok(wire) => wires.push(wire),
568            Err(e) => reader
569                .report
570                .warnings
571                .push(format!("D{de}: curve failed to build: {e}")),
572        }
573    }
574
575    // Stand-alone points: every independent point that is geometry, that
576    // no drawing lists and that nothing above took, as a vertex.
577    let point_des: Vec<i64> = file
578        .entities
579        .iter()
580        .filter(|(de, e)| {
581            e.kind == 116
582                && (e.status / 10_000) % 100 == 0
583                && (e.status / 100) % 100 == 0
584                && !on_sheets.contains(de)
585                && !reader.visited.contains_key(de)
586        })
587        .map(|(de, _)| *de)
588        .collect();
589    for de in point_des {
590        match reader.point(de) {
591            Ok(point) => points.push(point),
592            Err(e) => reader
593                .report
594                .warnings
595                .push(format!("D{de}: point failed to build: {e}")),
596        }
597    }
598
599    // Construction geometry: every entity [`construction_entities`] names
600    // that nothing above took, in directory order, by its name.
601    let mut construction = Vec::new();
602    for de in construction_des {
603        if reader.visited.contains_key(&de) {
604            continue;
605        }
606        match reader.construction_item(de) {
607            Ok(Some(geometry)) => {
608                let name = reader
609                    .long_name(de)
610                    .or_else(|| file.entity(de).map(|e| e.label.clone()))
611                    .filter(|n| !n.is_empty())
612                    .unwrap_or_else(|| format!("construction-{}", construction.len()));
613                construction.push((name, geometry));
614            }
615            Ok(None) => {}
616            Err(e) => reader
617                .report
618                .warnings
619                .push(format!("D{de}: construction item failed to build: {e}")),
620        }
621    }
622
623    if solids.is_empty()
624        && sheets.is_empty()
625        && wires.is_empty()
626        && points.is_empty()
627        && construction.is_empty()
628    {
629        // A deck that *had* candidates which all failed is a different
630        // refusal from one with nothing to try, and the warnings say why.
631        if reader.report.warnings.is_empty() {
632            ogeom_bail!(
633                Construction,
634                "the IGES file contains no manifold solid, no independent \
635                 trimmed or untrimmed surface, no independent curve or \
636                 point and no construction geometry this reader translates"
637            );
638        }
639        ogeom_bail!(
640            Construction,
641            "every shape in the IGES file failed to build: {}",
642            reader.report.warnings.join("; ")
643        );
644    }
645
646    if reader.vertex_misses.0 > 0 {
647        let (count, worst) = reader.vertex_misses;
648        reader.report.warnings.push(format!(
649            "{count} vertices sat off the curve ends they bound, by up to {worst:.2e}; \
650             their tolerances grew to say so"
651        ));
652    }
653    crate::bodies::rechart_beside_poles(
654        &mut reader.model,
655        solids.iter().map(|(_, s)| s).chain(sheets.iter()),
656        &mut reader.report.warnings,
657        tol,
658    )?;
659    let (callouts, dimensions) = reader.annotations();
660    // Groups and level lists become layers once the document stands.
661    for (de, entity) in &file.entities {
662        if (entity.kind == 402 && matches!(entity.form, 1 | 7 | 14 | 15))
663            || (entity.kind == 406 && entity.form == 1)
664        {
665            reader.visited.insert(*de, ());
666        }
667    }
668    // The solids' long names, read before the count below so their Name
669    // properties count as read.
670    let names: Vec<Option<String>> = solids.iter().map(|(de, _)| reader.long_name(*de)).collect();
671    // Everything never visited, counted by type and form.
672    for (de, entity) in &file.entities {
673        if !reader.visited.contains_key(de) {
674            let key = if entity.form == 0 {
675                format!("type {}", entity.kind)
676            } else {
677                format!("type {} form {}", entity.kind, entity.form)
678            };
679            *reader.report.skipped.entry(key).or_default() += 1;
680        }
681    }
682    let mut document = reader.document(&solids, &names, &sheets, construction);
683    {
684        let pmi = document.pmi_mut();
685        let base = pmi.dimensions.len();
686        for (i, dimension) in dimensions.into_iter().enumerate() {
687            let (callout, dimension) = dimension;
688            pmi.dimensions.push(dimension);
689            let mut callout = callout;
690            callout.annotates = Some(ogeom_doc::Annotated::Dimension(base + i));
691            pmi.callouts.push(callout);
692        }
693        pmi.callouts.extend(callouts);
694    }
695    layers(file, &mut document, &built_from, &mut reader.report);
696    let solids = solids.into_iter().map(|(_, s)| s).collect();
697    Ok(IgesImport {
698        document,
699        solids,
700        sheets,
701        wires,
702        points,
703        report: reader.report,
704    })
705}
706
707struct Reader<'a> {
708    file: &'a File,
709    /// The files the deck's external references name.
710    library: &'a Library,
711    model: Model,
712    report: IgesReport,
713    /// Every directory entry the reader consumed, for the skipped table.
714    visited: BTreeMap<i64, ()>,
715    /// Vertices by (vertex-list DE, 1-based index), shared, which is what
716    /// lets a closed shell close.
717    vertices: FastMap<(i64, i64), Shape>,
718    /// Edges by (edge-list DE, 1-based index), for the same reason.
719    edges: FastMap<(i64, i64), BuiltEdge>,
720    /// Edges cut where their curve runs through a pole of a face they
721    /// bound: the pieces, in order along the curve.
722    pieces: FastMap<(i64, i64), Vec<Shape>>,
723    /// The vertices made at poles, shared by every piece that meets one.
724    pole_vertices: Vec<Shape>,
725    /// The chart poles of every surface among the trimmed surfaces being
726    /// read to sew together: each boundary segment through one is cut
727    /// there, whichever face it bounds.
728    trim_poles: Vec<Point>,
729    /// Vertices a curve end missed by more than the confusion tolerance
730    /// and that widened to cover it: how many, and the widest miss.
731    vertex_misses: (usize, f64),
732    /// Edge images derived ahead of the face walk, by the face (510) that
733    /// wants them and the bits of the edge's range, each with the curve
734    /// it was derived for: [`Reader::image_of`] takes one whose curve is
735    /// the edge's, and derives any other itself.
736    images: FastMap<(i64, u64, u64), Vec<(Curve, DerivedImage)>>,
737    /// The face (510) being built, whose prepared images apply.
738    face_de: Option<i64>,
739    /// How many curve, surface or shell builders are open on the stack.
740    /// Entities refer to entities of their own kind (a composite curve to
741    /// its pieces, an offset surface to its basis), so a file that refers
742    /// in a circle would otherwise recurse until the stack runs out.
743    depth: u32,
744    tol: Tolerances,
745}
746
747/// How deep entities of one kind may refer to one another. Real files nest
748/// a handful of levels; a chain this long is a cycle or a hostile file.
749const MOST_NESTING: u32 = 64;
750
751impl<'a> Reader<'a> {
752    fn new(
753        file: &'a File,
754        model: Model,
755        scale_mm: f64,
756        library: &'a Library,
757        tol: Tolerances,
758    ) -> Self {
759        Reader {
760            file,
761            library,
762            model,
763            report: IgesReport {
764                scale_mm,
765                ..IgesReport::default()
766            },
767            visited: BTreeMap::new(),
768            vertices: FastMap::default(),
769            edges: FastMap::default(),
770            pieces: FastMap::default(),
771            pole_vertices: Vec::new(),
772            trim_poles: Vec::new(),
773            vertex_misses: (0, 0.0),
774            images: FastMap::default(),
775            face_de: None,
776            depth: 0,
777            tol,
778        }
779    }
780
781    /// Run a builder one level deeper, refusing past [`MOST_NESTING`].
782    fn nested<T>(&mut self, build: impl FnOnce(&mut Self) -> OgeomResult<T>) -> OgeomResult<T> {
783        if self.depth >= MOST_NESTING {
784            ogeom_bail!(
785                Construction,
786                "entities refer to one another more than {MOST_NESTING} deep; \
787                 the file refers in a circle"
788            );
789        }
790        self.depth += 1;
791        let built = build(self);
792        self.depth -= 1;
793        built
794    }
795
796    fn entity(&mut self, de: i64) -> OgeomResult<&'a Entity> {
797        let Some(entity) = self.file.entity(de) else {
798            ogeom_bail!(Construction, "IGES pointer D{de} names no entity");
799        };
800        self.visited.insert(de.abs(), ());
801        Ok(entity)
802    }
803
804    /// The model-space transform an entity carries, identity when none. A
805    /// transformation entity may itself be transformed; that composes.
806    fn placement(&mut self, entity: &Entity) -> OgeomResult<Transform> {
807        self.placement_within(entity, 0)
808    }
809
810    /// [`Self::placement`], `depth` transforms in. A chain of transforms
811    /// longer than any real file writes is a cycle (a transform placed by
812    /// itself), refused rather than followed until the stack runs out.
813    fn placement_within(&mut self, entity: &Entity, depth: u32) -> OgeomResult<Transform> {
814        const MOST_CHAINED: u32 = 32;
815        if entity.transform == 0 {
816            return Ok(Transform::IDENTITY);
817        }
818        if depth >= MOST_CHAINED {
819            ogeom_bail!(
820                Construction,
821                "transforms chain more than {MOST_CHAINED} deep; the file places a \
822                 transform by itself"
823            );
824        }
825        let de = entity.transform;
826        let t = self.entity(de)?;
827        if t.kind != 124 {
828            ogeom_bail!(
829                Construction,
830                "D{de}: a transformation pointer names a type {} entity",
831                t.kind
832            );
833        }
834        let v = |i: usize| t.at(i).real();
835        let s = self.report.scale_mm;
836        let linear = Matrix3::new([[v(0), v(1), v(2)], [v(4), v(5), v(6)], [v(8), v(9), v(10)]]);
837        let translation = Vector::new(v(3) * s, v(7) * s, v(11) * s);
838        let m = Transform::from_parts(linear, 1.0, translation, self.tol.angular())?;
839        if t.transform != 0 {
840            let outer = self.placement_within(t, depth + 1)?;
841            return Ok(outer * m);
842        }
843        Ok(m)
844    }
845
846    /// The motion that takes a child `parent` points at on from where its
847    /// own placement puts it into model space: the parent's placement
848    /// where the child is [`carried`], none where not.
849    fn parent_motion(&mut self, parent: &Entity, child: i64) -> OgeomResult<Transform> {
850        if self.file.entity(child).is_none_or(carried) {
851            self.placement(parent)
852        } else {
853            Ok(Transform::IDENTITY)
854        }
855    }
856
857    /// The motion that takes a child `parent` points at, once its own
858    /// placement has put it, into the parent's definition space: none for
859    /// a child [`carried`] by the parent's matrix, and the inverse of that
860    /// matrix for one that is not, so that the parent's placement of what
861    /// it builds leaves that child where it stands.
862    fn definition_motion(&mut self, parent: &Entity, child: i64) -> OgeomResult<Option<Transform>> {
863        if parent.transform == 0 || self.file.entity(child).is_none_or(carried) {
864            return Ok(None);
865        }
866        let placement = self.placement(parent)?;
867        if placement == Transform::IDENTITY {
868            return Ok(None);
869        }
870        Ok(Some(placement.inverse()?))
871    }
872
873    /// A curve `parent` points at, in the parent's definition space.
874    fn child_curve(&mut self, parent: &Entity, de: i64) -> OgeomResult<(Curve, (f64, f64))> {
875        let (curve, range) = self.curve(de)?;
876        match self.definition_motion(parent, de)? {
877            Some(motion) => Ok((curve.transformed(&motion, self.tol)?, range)),
878            None => Ok((curve, range)),
879        }
880    }
881
882    /// A surface `parent` points at, in the parent's definition space.
883    fn child_surface(&mut self, parent: &Entity, de: i64) -> OgeomResult<SurfaceGeometry> {
884        let surface = self.surface(de)?;
885        match self.definition_motion(parent, de)? {
886            Some(motion) => Ok(surface.transformed(&motion, self.tol)?),
887            None => Ok(surface),
888        }
889    }
890
891    fn point3(&self, e: &Entity, i: usize) -> Point {
892        let s = self.report.scale_mm;
893        Point::new(
894            e.at(i).real() * s,
895            e.at(i + 1).real() * s,
896            e.at(i + 2).real() * s,
897        )
898    }
899
900    /// A model-space curve with the range its own definition covers.
901    fn curve(&mut self, de: i64) -> OgeomResult<(Curve, (f64, f64))> {
902        self.nested(|reader| reader.curve_at(de))
903    }
904
905    fn curve_at(&mut self, de: i64) -> OgeomResult<(Curve, (f64, f64))> {
906        let entity = self.entity(de)?;
907        let scale = self.report.scale_mm;
908        let (curve, range) = match entity.kind {
909            110 => {
910                let a = self.point3(entity, 0);
911                let b = self.point3(entity, 3);
912                let line = LineCurve::segment(a, b, self.tol)?;
913                (Curve::from(line), (0.0, a.distance(b)))
914            }
915            100 => {
916                // Centre, start and end in the definition plane at z = zt;
917                // the arc runs counter-clockwise from start to end.
918                let zt = entity.at(0).real() * scale;
919                let c = Point::new(entity.at(1).real() * scale, entity.at(2).real() * scale, zt);
920                let s = Point::new(entity.at(3).real() * scale, entity.at(4).real() * scale, zt);
921                let e = Point::new(entity.at(5).real() * scale, entity.at(6).real() * scale, zt);
922                let radius = c.distance(s);
923                let x = Direction::new(s - c, self.tol)?;
924                let frame = Frame::new(c, Direction::Z, x, self.tol)?;
925                let circle = Circle::new(frame, radius, self.tol)?;
926                let to_end = e - c;
927                let ang = to_end
928                    .dot(frame.y().vector())
929                    .atan2(to_end.dot(frame.x().vector()))
930                    .rem_euclid(core::f64::consts::TAU);
931                let sweep = if ang <= self.tol.parametric() {
932                    core::f64::consts::TAU
933                } else {
934                    ang
935                };
936                (Curve::from(CircleCurve::new(circle)), (0.0, sweep))
937            }
938            104 => self.conic(de, entity)?,
939            112 => self.spline_curve(de, entity)?,
940            126 => self.nurbs_curve(de, entity)?,
941            130 => self.offset_curve(de, entity)?,
942            106 => self.copious_path(de, entity)?,
943            102 => ogeom_bail!(
944                Construction,
945                "D{de}: a composite curve is a sequence, not a curve; the \
946                 caller walks its segments"
947            ),
948            kind => ogeom_bail!(
949                Construction,
950                "D{de}: curve entity type {kind}{} is not translated; see \
951                 docs/CAPABILITIES.md, io.iges",
952                super::entity_name(kind).map_or_else(String::new, |n| format!(" ({n})"))
953            ),
954        };
955        // The entity's placement moves the curve into model space.
956        let placement = self.placement(entity)?;
957        if placement == Transform::IDENTITY {
958            Ok((curve, range))
959        } else {
960            Ok((curve.transformed(&placement, self.tol)?, range))
961        }
962    }
963
964    /// Copious data drawn as a path (106, forms 11, 12, 13 and 63): its
965    /// points joined by straight segments, as the degree-one B-spline
966    /// through them, exact, one span per segment and the parameter the
967    /// point's index. The interpretation flag says how the points are
968    /// listed: pairs in a plane at a common height, triples, or triples
969    /// each followed by a vector, which the path does not use. Form 63 is
970    /// a closed planar path, its last point the first again. A point
971    /// repeating the one before it adds no segment.
972    fn copious_path(&mut self, de: i64, entity: &Entity) -> OgeomResult<(Curve, (f64, f64))> {
973        if !matches!(entity.form, 11 | 12 | 13 | 63) {
974            ogeom_bail!(
975                Construction,
976                "D{de}: copious data form {} is a set of points, not a curve",
977                entity.form
978            );
979        }
980        let scale = self.report.scale_mm;
981        let flag = entity.at(0).int();
982        let count = entity.count(1);
983        let (first, stride) = match flag {
984            1 => (3, 2),
985            2 => (2, 3),
986            3 => (2, 6),
987            other => ogeom_bail!(
988                Construction,
989                "D{de}: copious data interpretation flag {other} is not 1, 2 or 3"
990            ),
991        };
992        let height = if flag == 1 { entity.at(2).real() } else { 0.0 };
993        let mut points: Vec<Point> = Vec::with_capacity(count + 1);
994        for i in 0..count {
995            let at = first + i * stride;
996            let z = if flag == 1 {
997                height
998            } else {
999                entity.at(at + 2).real()
1000            };
1001            let p = Point::new(
1002                entity.at(at).real() * scale,
1003                entity.at(at + 1).real() * scale,
1004                z * scale,
1005            );
1006            if points
1007                .last()
1008                .is_none_or(|q| q.distance(p) > self.tol.confusion())
1009            {
1010                points.push(p);
1011            }
1012        }
1013        if entity.form == 63
1014            && let (Some(&head), Some(&tail)) = (points.first(), points.last())
1015            && head.distance(tail) > self.tol.confusion()
1016        {
1017            points.push(head);
1018        }
1019        if points.len() < 2 {
1020            ogeom_bail!(Construction, "D{de}: copious data draws no segment");
1021        }
1022        let n = points.len();
1023        let mut knots = Vec::with_capacity(n + 2);
1024        knots.push(0.0);
1025        #[allow(clippy::cast_precision_loss, reason = "point counts")]
1026        knots.extend((0..n).map(|i| i as f64));
1027        #[allow(clippy::cast_precision_loss, reason = "point counts")]
1028        knots.push((n - 1) as f64);
1029        let curve = BSplineCurve::new(KnotVector::new(knots, 1)?, points, self.tol)?;
1030        #[allow(clippy::cast_precision_loss, reason = "point counts")]
1031        let range = (0.0, (n - 1) as f64);
1032        Ok((Curve::from(curve), range))
1033    }
1034
1035    /// Conic arc: `A x² + B xy + C y² + D x + E y + F = 0` in the definition
1036    /// plane. The coefficients say which conic it is (both squares one sign
1037    /// an ellipse, opposite signs a hyperbola, one square missing a
1038    /// parabola), and each translates to its own curve, the arc's ends read
1039    /// off the start and terminate points. A conic whose axes turn (`B` not
1040    /// zero) is read in the frame turned by `θ`, `tan 2θ = B / (A − C)`,
1041    /// where its cross term vanishes, and turned back: the curve is exact
1042    /// either way.
1043    fn conic(&mut self, de: i64, entity: &Entity) -> OgeomResult<(Curve, (f64, f64))> {
1044        let scale = self.report.scale_mm;
1045        let (a, b, c, d, e, f) = (
1046            entity.at(0).real(),
1047            entity.at(1).real(),
1048            entity.at(2).real(),
1049            entity.at(3).real(),
1050            entity.at(4).real(),
1051            entity.at(5).real(),
1052        );
1053        let zt = entity.at(6).real() * scale;
1054        let start = Point::new(entity.at(7).real() * scale, entity.at(8).real() * scale, zt);
1055        let end = Point::new(
1056            entity.at(9).real() * scale,
1057            entity.at(10).real() * scale,
1058            zt,
1059        );
1060        if b.abs() > 1e-12 {
1061            let theta = 0.5 * b.atan2(a - c);
1062            let (sin, cos) = theta.sin_cos();
1063            // x = x' cos θ − y' sin θ, y = x' sin θ + y' cos θ.
1064            let turned = [
1065                a * cos * cos + b * cos * sin + c * sin * sin,
1066                a * sin * sin - b * cos * sin + c * cos * cos,
1067                d * cos + e * sin,
1068                -d * sin + e * cos,
1069                f,
1070            ];
1071            let about = ogeom_math::Axis::new(Point::ORIGIN, Direction::Z);
1072            let back = Transform::rotation(about, theta);
1073            let into = Transform::rotation(about, -theta);
1074            let (curve, range) =
1075                self.axis_aligned_conic(de, turned, zt, into.apply(start), into.apply(end))?;
1076            return Ok((curve.transformed(&back, self.tol)?, range));
1077        }
1078        self.axis_aligned_conic(de, [a, c, d, e, f], zt, start, end)
1079    }
1080
1081    /// [`Reader::conic`] once its axes are the definition plane's own:
1082    /// `A x² + C y² + D x + E y + F = 0`.
1083    fn axis_aligned_conic(
1084        &mut self,
1085        de: i64,
1086        [a, c, d, e, f]: [f64; 5],
1087        zt: f64,
1088        start: Point,
1089        end: Point,
1090    ) -> OgeomResult<(Curve, (f64, f64))> {
1091        let scale = self.report.scale_mm;
1092        // Both squares present and of one sign: an ellipse, its coefficients
1093        // made positive.
1094        if a != 0.0 && c != 0.0 && (a > 0.0) == (c > 0.0) {
1095            let sign = if a > 0.0 { 1.0 } else { -1.0 };
1096            return self.ellipse_arc(de, [a, c, d, e, f].map(|k| k * sign), zt, start, end);
1097        }
1098        if a != 0.0 && c != 0.0 {
1099            return Self::hyperbola_arc(de, [a, c, d, e, f], zt, start, end, scale, self.tol);
1100        }
1101        if (a == 0.0) != (c == 0.0) {
1102            return Self::parabola_arc(de, [a, c, d, e, f], zt, start, end, scale, self.tol);
1103        }
1104        ogeom_bail!(Construction, "D{de}: conic arc coefficients close no conic")
1105    }
1106
1107    /// The ellipse arm of [`Reader::conic`]: `A x² + C y² + D x + E y + F = 0`
1108    /// with `A` and `C` positive.
1109    fn ellipse_arc(
1110        &mut self,
1111        de: i64,
1112        [a, c, d, e, f]: [f64; 5],
1113        zt: f64,
1114        start: Point,
1115        end: Point,
1116    ) -> OgeomResult<(Curve, (f64, f64))> {
1117        let scale = self.report.scale_mm;
1118        let cx = -d / (2.0 * a);
1119        let cy = -e / (2.0 * c);
1120        let rhs = a * cx * cx + c * cy * cy - f;
1121        let (ra2, rb2) = (rhs / a, rhs / c);
1122        if ra2 <= 0.0 || rb2 <= 0.0 {
1123            ogeom_bail!(
1124                Construction,
1125                "D{de}: conic arc coefficients close no ellipse"
1126            );
1127        }
1128        let centre = Point::new(cx * scale, cy * scale, zt);
1129        let (rx, ry) = (ra2.sqrt() * scale, rb2.sqrt() * scale);
1130        // The ellipse type wants major ≥ minor; when the x semi-axis is the
1131        // smaller one, a quarter-turn of the frame swaps the roles.
1132        let (frame, major, minor) = if rx >= ry {
1133            (
1134                Frame::new(centre, Direction::Z, Direction::X, self.tol)?,
1135                rx,
1136                ry,
1137            )
1138        } else {
1139            (
1140                Frame::new(centre, Direction::Z, Direction::Y, self.tol)?,
1141                ry,
1142                rx,
1143            )
1144        };
1145        let ellipse = Ellipse::new(frame, major, minor, self.tol)?;
1146        let angle_of = |p: Point| -> f64 {
1147            let local = frame.to_local(p);
1148            (local.y / minor)
1149                .atan2(local.x / major)
1150                .rem_euclid(core::f64::consts::TAU)
1151        };
1152        let t0 = angle_of(start);
1153        let mut t1 = angle_of(end);
1154        if t1 <= t0 + self.tol.parametric() {
1155            t1 += core::f64::consts::TAU;
1156        }
1157        Ok((Curve::from(EllipseCurve::new(ellipse)), (t0, t1)))
1158    }
1159
1160    /// The hyperbola arm of [`Reader::conic`]: squares of opposite sign.
1161    /// The branch is the one the arc's ends stand on, its parameter the
1162    /// natural one, `(a cosh t, b sinh t)`, and the arc runs in increasing
1163    /// parameter, the frame turned over where the file's ends run the other
1164    /// way.
1165    fn hyperbola_arc(
1166        de: i64,
1167        [a, c, d, e, f]: [f64; 5],
1168        zt: f64,
1169        start: Point,
1170        end: Point,
1171        scale: f64,
1172        tol: Tolerances,
1173    ) -> OgeomResult<(Curve, (f64, f64))> {
1174        let cx = -d / (2.0 * a);
1175        let cy = -e / (2.0 * c);
1176        let rhs = a * cx * cx + c * cy * cy - f;
1177        if rhs == 0.0 {
1178            ogeom_bail!(
1179                Construction,
1180                "D{de}: conic arc coefficients close no hyperbola; they cross"
1181            );
1182        }
1183        // Divided through by the right-hand side, the positive square names
1184        // the transverse axis.
1185        let (pa, pc) = (a / rhs, c / rhs);
1186        let (major, minor, along_x) = if pa > 0.0 {
1187            ((1.0 / pa).sqrt(), (-1.0 / pc).sqrt(), true)
1188        } else {
1189            ((1.0 / pc).sqrt(), (-1.0 / pa).sqrt(), false)
1190        };
1191        let centre = Point::new(cx * scale, cy * scale, zt);
1192        let axis = if along_x { Direction::X } else { Direction::Y };
1193        // The branch: where the ends stand along the transverse axis.
1194        let side = (start - centre).dot(axis.vector());
1195        let x = if side >= 0.0 {
1196            axis
1197        } else {
1198            Direction::new(-axis.vector(), tol)?
1199        };
1200        let build = |z: Direction| -> OgeomResult<(Curve, (f64, f64))> {
1201            let frame = Frame::new(centre, z, x, tol)?;
1202            let (major, minor) = (major * scale, minor * scale);
1203            let hyperbola = Hyperbola::new(frame, major, minor, tol)?;
1204            let t_of = |p: Point| (frame.to_local(p).y / minor).asinh();
1205            let (t0, t1) = (t_of(start), t_of(end));
1206            let extent = t0.abs().max(t1.abs()).max(1e-3) * 1.5;
1207            Ok((
1208                Curve::from(HyperbolaCurve::new(hyperbola, extent)?),
1209                (t0, t1),
1210            ))
1211        };
1212        let (curve, (t0, t1)) = build(Direction::Z)?;
1213        if t1 > t0 {
1214            return Ok((curve, (t0, t1)));
1215        }
1216        // Turned over: the same branch, the parameter running the other way.
1217        let (curve, (t0, t1)) = build(Direction::new(-Direction::Z.vector(), tol)?)?;
1218        Ok((curve, (t0, t1)))
1219    }
1220
1221    /// The parabola arm of [`Reader::conic`]: one square missing. The axis
1222    /// is the missing square's direction; the parameter runs along the
1223    /// other, as this vocabulary's parabola does.
1224    fn parabola_arc(
1225        de: i64,
1226        [a, c, d, e, f]: [f64; 5],
1227        zt: f64,
1228        start: Point,
1229        end: Point,
1230        scale: f64,
1231        tol: Tolerances,
1232    ) -> OgeomResult<(Curve, (f64, f64))> {
1233        // `A x² + D x + E y + F = 0` opens along y; `C y² + D x + E y + F = 0`
1234        // along x. Either is `axis = apex + k (across - apex)²`.
1235        let (apex_across, apex_along, k, along) = if c == 0.0 {
1236            if e == 0.0 {
1237                ogeom_bail!(
1238                    Construction,
1239                    "D{de}: conic arc coefficients close no parabola"
1240                );
1241            }
1242            let x0 = -d / (2.0 * a);
1243            let y0 = -(a * x0 * x0 + d * x0 + f) / e;
1244            (x0, y0, -a / e, Direction::Y)
1245        } else {
1246            if d == 0.0 {
1247                ogeom_bail!(
1248                    Construction,
1249                    "D{de}: conic arc coefficients close no parabola"
1250                );
1251            }
1252            let y0 = -e / (2.0 * c);
1253            let x0 = -(c * y0 * y0 + e * y0 + f) / d;
1254            (y0, x0, -c / d, Direction::X)
1255        };
1256        let apex = if c == 0.0 {
1257            Point::new(apex_across * scale, apex_along * scale, zt)
1258        } else {
1259            Point::new(apex_along * scale, apex_across * scale, zt)
1260        };
1261        // The axis points the way the parabola opens.
1262        let x = if k >= 0.0 {
1263            along
1264        } else {
1265            Direction::new(-along.vector(), tol)?
1266        };
1267        // `axis = k across²` against `axis = across² / (4 f)`.
1268        let focal = scale / (4.0 * k.abs());
1269        let build = |z: Direction| -> OgeomResult<(Curve, (f64, f64))> {
1270            let frame = Frame::new(apex, z, x, tol)?;
1271            let parabola = Parabola::new(frame, focal, tol)?;
1272            let t_of = |p: Point| frame.to_local(p).y;
1273            let (t0, t1) = (t_of(start), t_of(end));
1274            let extent = t0.abs().max(t1.abs()).max(1e-3) * 1.5;
1275            Ok((Curve::from(ParabolaCurve::new(parabola, extent)?), (t0, t1)))
1276        };
1277        let (curve, (t0, t1)) = build(Direction::Z)?;
1278        if t1 > t0 {
1279            return Ok((curve, (t0, t1)));
1280        }
1281        let (curve, (t0, t1)) = build(Direction::new(-Direction::Z.vector(), tol)?)?;
1282        Ok((curve, (t0, t1)))
1283    }
1284
1285    /// Offset curve (130): a base curve displaced perpendicular to a
1286    /// reference direction. The file displaces along the reference crossed
1287    /// with the tangent; this vocabulary's offset runs along the tangent
1288    /// crossed with the reference, so the distance flips sign. A constant
1289    /// distance is the exact offset curve. A distance that varies (type 2,
1290    /// linearly with arc length from `D1` at `TD1` to `D2` at `TD2`; type 3,
1291    /// as a coordinate of another curve at the same parameter) has no
1292    /// closed form, and the displaced curve is fitted, same-parameter with
1293    /// the base curve, to a hundredth of a micron between its samples as
1294    /// well as at them, or the reach is warned of.
1295    fn offset_curve(&mut self, de: i64, entity: &Entity) -> OgeomResult<(Curve, (f64, f64))> {
1296        let scale = self.report.scale_mm;
1297        let kind = entity.at(1).int();
1298        let (d1, d2) = (entity.at(5).real(), entity.at(7).real());
1299        if kind != 1 || (d1 - d2).abs() > 1e-12 {
1300            return self.varying_offset(de, entity, kind);
1301        }
1302        let (basis, range) = self.child_curve(entity, entity.at(0).int())?;
1303        let reference = Direction::from_coords(
1304            entity.at(9).real(),
1305            entity.at(10).real(),
1306            entity.at(11).real(),
1307            self.tol,
1308        )?;
1309        let (tt1, tt2) = (entity.at(12).real(), entity.at(13).real());
1310        let range = if tt2 > tt1 { (tt1, tt2) } else { range };
1311        let offset = OffsetCurve::new(basis, -d1 * scale, reference)?;
1312        Ok((Curve::Offset(Box::new(offset)), range))
1313    }
1314
1315    /// The varying arm of [`Reader::offset_curve`].
1316    fn varying_offset(
1317        &mut self,
1318        de: i64,
1319        entity: &Entity,
1320        kind: i64,
1321    ) -> OgeomResult<(Curve, (f64, f64))> {
1322        let scale = self.report.scale_mm;
1323        let (basis, range) = self.child_curve(entity, entity.at(0).int())?;
1324        let reference = Direction::from_coords(
1325            entity.at(9).real(),
1326            entity.at(10).real(),
1327            entity.at(11).real(),
1328            self.tol,
1329        )?
1330        .vector();
1331        let (tt1, tt2) = (entity.at(12).real(), entity.at(13).real());
1332        let range = if tt2 > tt1 { (tt1, tt2) } else { range };
1333        // Sampled at every span of the base, and the fit held to its
1334        // target between the samples as well as at them, against the law
1335        // evaluated wherever the fit is measured.
1336        const SAMPLES: usize = 400;
1337        let stations = ogeom_algo::traced::stations(&basis, range, SAMPLES);
1338        let distance: Box<dyn Fn(f64) -> OgeomResult<f64>> = match kind {
1339            2 => {
1340                // Linear in arc length along the base, from TD1 to TD2: the
1341                // length to each station integrated once, and from the
1342                // station below to any parameter on demand.
1343                let (d1, td1, d2, td2) = (
1344                    entity.at(5).real(),
1345                    entity.at(6).real(),
1346                    entity.at(7).real(),
1347                    entity.at(8).real(),
1348                );
1349                let mut lengths = vec![0.0_f64];
1350                for pair in stations.windows(2) {
1351                    let held = lengths[lengths.len() - 1];
1352                    lengths.push(
1353                        held + ogeom_algo::curve_length(&basis, (pair[0], pair[1]), self.tol)?
1354                            / scale,
1355                    );
1356                }
1357                let span = td2 - td1;
1358                let (basis, stations, tol) = (basis.clone(), stations.clone(), self.tol);
1359                Box::new(move |t| {
1360                    let i = stations.partition_point(|&s| s <= t).saturating_sub(1);
1361                    let along = lengths[i]
1362                        + ogeom_algo::curve_length(&basis, (stations[i], t), tol)? / scale;
1363                    let f = if span.abs() > 0.0 {
1364                        (along - td1) / span
1365                    } else {
1366                        0.0
1367                    };
1368                    Ok(d1 + (d2 - d1) * f)
1369                })
1370            }
1371            3 => {
1372                // A coordinate (TT: 1 x, 2 y, 3 z) of another curve at the
1373                // same parameter.
1374                let (function, _) = self.curve(entity.at(2).int())?;
1375                let which = entity.at(3).int();
1376                let tol = self.tol;
1377                Box::new(move |t| {
1378                    let p = function.point_at(t, tol)?;
1379                    let value = match which {
1380                        1 => p.x,
1381                        2 => p.y,
1382                        _ => p.z,
1383                    };
1384                    Ok(value / scale)
1385                })
1386            }
1387            _ => ogeom_bail!(
1388                Construction,
1389                "D{de}: offset curve type {kind} names no distance law"
1390            ),
1391        };
1392        let tol = self.tol;
1393        let displaced = |t: f64| -> OgeomResult<Point> {
1394            let p = basis.point_at(t, tol)?;
1395            let tangent = basis.d1_at(t, tol)?;
1396            let side = reference.cross(tangent);
1397            let m = side.magnitude();
1398            if m <= tol.angular() {
1399                ogeom_bail!(
1400                    Construction,
1401                    "D{de}: the offset's reference runs along its base curve"
1402                );
1403            }
1404            Ok(p + side * (distance(t)? * scale / m))
1405        };
1406        let fitted = ogeom_algo::traced::fit_traced(
1407            displaced,
1408            &stations,
1409            3,
1410            self.tol.confusion() * 100.0,
1411            self.tol,
1412        )?;
1413        if !fitted.met {
1414            self.report.warnings.push(format!(
1415                "D{de}: a varying offset curve fitted to {:.2e}",
1416                fitted.error
1417            ));
1418        }
1419        Ok((Curve::from(fitted.curve), range))
1420    }
1421
1422    fn spline_curve(&mut self, de: i64, entity: &Entity) -> OgeomResult<(Curve, (f64, f64))> {
1423        let n = entity.count(3);
1424        if n == 0 {
1425            ogeom_bail!(Construction, "D{de}: a spline curve with no segments");
1426        }
1427        let scale = self.report.scale_mm;
1428        // Break points T(1..=N+1), then 12 coefficients per segment; the
1429        // polynomial argument runs over [0, h] within each span.
1430        let t = |i: usize| entity.at(4 + i).real();
1431        let base = 4 + n + 1;
1432        let mut control: Vec<Point> = Vec::with_capacity(3 * n + 1);
1433        let mut knots: Vec<f64> = vec![t(0); 4];
1434        for seg in 0..n {
1435            let h = t(seg + 1) - t(seg);
1436            if h <= 0.0 {
1437                ogeom_bail!(Construction, "D{de}: spline segment {seg} has no span");
1438            }
1439            let co = |k: usize| entity.at(base + 12 * seg + k).real();
1440            let (ax, bx, cx, dx) = (co(0), co(1), co(2), co(3));
1441            let (ay, by, cy, dy) = (co(4), co(5), co(6), co(7));
1442            let (az, bz, cz, dz) = (co(8), co(9), co(10), co(11));
1443            let at = |s: f64| {
1444                Point::new(
1445                    (ax + s * (bx + s * (cx + s * dx))) * scale,
1446                    (ay + s * (by + s * (cy + s * dy))) * scale,
1447                    (az + s * (bz + s * (cz + s * dz))) * scale,
1448                )
1449            };
1450            // Bernstein form of a cubic on [0, h]: the endpoints, and one
1451            // third of the end derivatives standing off them.
1452            let p0 = at(0.0);
1453            let p3 = at(h);
1454            let d0 = Vector::new(bx, by, bz) * (h * scale / 3.0);
1455            let d1 = Vector::new(
1456                bx + 2.0 * cx * h + 3.0 * dx * h * h,
1457                by + 2.0 * cy * h + 3.0 * dy * h * h,
1458                bz + 2.0 * cz * h + 3.0 * dz * h * h,
1459            ) * (h * scale / 3.0);
1460            if seg == 0 {
1461                control.push(p0);
1462            }
1463            control.push(p0 + d0);
1464            control.push(p3 - d1);
1465            control.push(p3);
1466            if seg + 1 < n {
1467                knots.extend([t(seg + 1); 3]);
1468            }
1469        }
1470        knots.extend([t(n); 4]);
1471        let curve = BSplineCurve::new(KnotVector::new(knots, 3)?, control, self.tol)?;
1472        let range = ogeom_geom::Curve3d::domain(&curve);
1473        Ok((Curve::from(curve), range))
1474    }
1475
1476    /// Rational B-spline curve, the direct translation.
1477    fn nurbs_curve(&mut self, de: i64, entity: &Entity) -> OgeomResult<(Curve, (f64, f64))> {
1478        let k = entity.count(0);
1479        let degree = entity.count(1);
1480        if degree == 0 {
1481            ogeom_bail!(Construction, "D{de}: a B-spline curve of degree zero");
1482        }
1483        let n_ctrl = k + 1;
1484        let n_knots = n_ctrl + degree + 1;
1485        let knots: Vec<f64> = (0..n_knots).map(|i| entity.at(6 + i).real()).collect();
1486        let w_base = 6 + n_knots;
1487        let p_base = w_base + n_ctrl;
1488        let scale = self.report.scale_mm;
1489        let mut control = Vec::with_capacity(n_ctrl);
1490        for i in 0..n_ctrl {
1491            let w = entity.at(w_base + i).real();
1492            let p = Point::new(
1493                entity.at(p_base + 3 * i).real() * scale,
1494                entity.at(p_base + 3 * i + 1).real() * scale,
1495                entity.at(p_base + 3 * i + 2).real() * scale,
1496            );
1497            control.push(Weighted::new(p, w, self.tol)?);
1498        }
1499        let curve = BSplineCurve::rational(KnotVector::new(knots, degree)?, control)?;
1500        let v0 = entity.at(p_base + 3 * n_ctrl).real();
1501        let v1 = entity.at(p_base + 3 * n_ctrl + 1).real();
1502        let domain = ogeom_geom::Curve3d::domain(&curve);
1503        let range = if v1 > v0 { (v0, v1) } else { domain };
1504        Ok((Curve::from(curve), range))
1505    }
1506
1507    /// A model-space surface.
1508    fn surface(&mut self, de: i64) -> OgeomResult<SurfaceGeometry> {
1509        self.nested(|reader| reader.surface_at(de))
1510    }
1511
1512    fn surface_at(&mut self, de: i64) -> OgeomResult<SurfaceGeometry> {
1513        let entity = self.entity(de)?;
1514        let scale = self.report.scale_mm;
1515        let surface: SurfaceGeometry = match entity.kind {
1516            108 => {
1517                // A x + B y + C z = D, unbounded; any face's trim bounds it.
1518                let normal = Vector::new(
1519                    entity.at(0).real(),
1520                    entity.at(1).real(),
1521                    entity.at(2).real(),
1522                );
1523                let d = entity.at(3).real();
1524                let origin = Point::ORIGIN + normal * (d / normal.dot(normal)) * scale;
1525                let plane = Plane::through(origin, Direction::new(normal, self.tol)?);
1526                PlaneSurface::over(
1527                    plane,
1528                    (-SURFACE_EXTENT, SURFACE_EXTENT),
1529                    (-SURFACE_EXTENT, SURFACE_EXTENT),
1530                )?
1531                .into()
1532            }
1533            190 => {
1534                let point = self.location_entity(entity.at(0).int())?;
1535                let normal = self.direction_entity(entity.at(1).int())?;
1536                // A parameterized plane names the direction its `u` runs.
1537                let plane = match self.reference_direction(entity, 2)? {
1538                    Some(x) => Plane::new(Frame::new(point, normal, x, self.tol)?),
1539                    None => Plane::through(point, normal),
1540                };
1541                PlaneSurface::over(
1542                    plane,
1543                    (-SURFACE_EXTENT, SURFACE_EXTENT),
1544                    (-SURFACE_EXTENT, SURFACE_EXTENT),
1545                )?
1546                .into()
1547            }
1548            120 => {
1549                // Axis line, generatrix, start and terminate angles.
1550                let axis = {
1551                    let (line, _) = self.child_curve(entity, entity.at(0).int())?;
1552                    let Curve::Line(l) = line else {
1553                        ogeom_bail!(
1554                            Construction,
1555                            "D{de}: a surface of revolution's axis is not a line"
1556                        );
1557                    };
1558                    l.axis()
1559                };
1560                let (curve, range) = self.child_curve(entity, entity.at(1).int())?;
1561                let mut curve = trimmed_to(curve, range, self.tol)?;
1562                let sa = entity.at(2).real();
1563                let ta = entity.at(3).real();
1564                let sweep = if ta > sa {
1565                    ta - sa
1566                } else {
1567                    core::f64::consts::TAU
1568                };
1569                if sa.abs() > self.tol.parametric() {
1570                    curve = curve.transformed(&Transform::rotation(axis, sa), self.tol)?;
1571                }
1572                RevolutionSurface::new(curve, axis, sweep)?.into()
1573            }
1574            122 => {
1575                // Directrix, plus the far end of a generator drawn through
1576                // the directrix's start point.
1577                let (curve, range) = self.child_curve(entity, entity.at(0).int())?;
1578                let start = curve.point_at(range.0, self.tol)?;
1579                let far = self.point3(entity, 1);
1580                let vec = far - start;
1581                let curve = trimmed_to(curve, range, self.tol)?;
1582                ExtrusionSurface::new(curve, Direction::new(vec, self.tol)?, vec.magnitude())?
1583                    .into()
1584            }
1585            128 => self.nurbs_surface(de, entity)?,
1586            114 => self.spline_surface(de, entity)?,
1587            118 => self.ruled_surface(de, entity)?,
1588            140 => {
1589                // Normal, distance, base surface: displaced along the
1590                // file's direction, which is the base's own normal or its
1591                // opposite; the sign carries the difference.
1592                let given = Direction::from_coords(
1593                    entity.at(0).real(),
1594                    entity.at(1).real(),
1595                    entity.at(2).real(),
1596                    self.tol,
1597                )?;
1598                let distance = entity.at(3).real() * scale;
1599                let basis = self.child_surface(entity, entity.at(4).int())?;
1600                let ((ua, ub), (va, vb)) = ogeom_geom::Surface::domain(&basis);
1601                let own = ogeom_geom::Surface::normal_at(
1602                    &basis,
1603                    f64::midpoint(ua, ub),
1604                    f64::midpoint(va, vb),
1605                    self.tol,
1606                )?;
1607                let signed = if own.vector().dot(given.vector()) < 0.0 {
1608                    -distance
1609                } else {
1610                    distance
1611                };
1612                SurfaceGeometry::Offset(Box::new(OffsetSurface::new(basis, signed)?))
1613            }
1614            192 => {
1615                let point = self.location_entity(entity.at(0).int())?;
1616                let dir = self.direction_entity(entity.at(1).int())?;
1617                let radius = entity.at(2).real() * scale;
1618                let frame = self.framed(point, dir, entity, 3)?;
1619                CylinderSurface::new(
1620                    Cylinder::new(frame, radius, self.tol)?,
1621                    (-SURFACE_EXTENT, SURFACE_EXTENT),
1622                )?
1623                .into()
1624            }
1625            194 => {
1626                let point = self.location_entity(entity.at(0).int())?;
1627                let dir = self.direction_entity(entity.at(1).int())?;
1628                let radius = entity.at(2).real() * scale;
1629                let half_angle = entity.at(3).real().to_radians();
1630                let frame = self.framed(point, dir, entity, 4)?;
1631                ConeSurface::new(
1632                    Cone::new(frame, radius, half_angle, self.tol)?,
1633                    (-SURFACE_EXTENT, SURFACE_EXTENT),
1634                )?
1635                .into()
1636            }
1637            196 => {
1638                let centre = self.location_entity(entity.at(0).int())?;
1639                let radius = entity.at(1).real() * scale;
1640                // A parameterized sphere names its axis and where `u` starts.
1641                if entity.at(2).int() != 0 {
1642                    let axis = self.direction_entity(entity.at(2).int())?;
1643                    let frame = self.framed(centre, axis, entity, 3)?;
1644                    SphereSurface::new(Sphere::new(frame, radius, self.tol)?).into()
1645                } else {
1646                    SphereSurface::new(Sphere::centred(centre, radius, self.tol)?).into()
1647                }
1648            }
1649            198 => {
1650                let centre = self.location_entity(entity.at(0).int())?;
1651                let dir = self.direction_entity(entity.at(1).int())?;
1652                let major = entity.at(2).real() * scale;
1653                let minor = entity.at(3).real() * scale;
1654                let frame = self.framed(centre, dir, entity, 4)?;
1655                TorusSurface::new(Torus::new(frame, major, minor, self.tol)?).into()
1656            }
1657            kind => ogeom_bail!(
1658                Construction,
1659                "D{de}: surface entity type {kind}{} is not translated; see \
1660                 docs/CAPABILITIES.md, io.iges",
1661                super::entity_name(kind).map_or_else(String::new, |n| format!(" ({n})"))
1662            ),
1663        };
1664        let placement = self.placement(entity)?;
1665        if placement == Transform::IDENTITY {
1666            Ok(surface)
1667        } else {
1668            Ok(surface.transformed(&placement, self.tol)?)
1669        }
1670    }
1671
1672    /// Ruled surface (118): the straight lines between points of equal
1673    /// parameter on two curves, as a patch of degree one across. Both
1674    /// curves in their exact spline form over `[0, 1]`, raised to one
1675    /// degree and refined to one knot vector, the second walked backward
1676    /// where the file's direction flag says so. The file's form 0 joins
1677    /// points of equal *arc length* fraction; that is read as equal
1678    /// parameter, exact for the uniform-speed curves and a hair off for the
1679    /// rest.
1680    fn ruled_surface(&mut self, de: i64, entity: &Entity) -> OgeomResult<SurfaceGeometry> {
1681        let (first, first_range) = self.child_curve(entity, entity.at(0).int())?;
1682        let (second, second_range) = self.child_curve(entity, entity.at(1).int())?;
1683        let mut a = first.to_bspline_over(first_range, self.tol)?;
1684        let mut b = second.to_bspline_over(second_range, self.tol)?;
1685        if entity.at(2).int() == 1 {
1686            let Curve::BSpline(turned) =
1687                ogeom_geom::Reversible::reversed(&Curve::BSpline(b.clone()))
1688            else {
1689                ogeom_bail!(Construction, "D{de}: a reversed spline is not a spline");
1690            };
1691            b = turned;
1692        }
1693        while a.degree() < b.degree() {
1694            a = a.elevated(self.tol)?;
1695        }
1696        while b.degree() < a.degree() {
1697            b = b.elevated(self.tol)?;
1698        }
1699        let tol = self.tol;
1700        let unify = |target: &mut BSplineCurve, source: &BSplineCurve| -> OgeomResult<()> {
1701            let (lo, hi) = source.knots().domain();
1702            for (value, multiplicity) in source.knots().distinct() {
1703                if value <= lo + 1e-12 || value >= hi - 1e-12 {
1704                    continue;
1705                }
1706                let held = target.knots().multiplicity_of(value);
1707                if multiplicity > held {
1708                    *target = target.with_knot_inserted(value, multiplicity - held, tol)?;
1709                }
1710            }
1711            Ok(())
1712        };
1713        unify(&mut a, &b)?;
1714        unify(&mut b, &a)?;
1715        let nu = a.control_points().len();
1716        if b.control_points().len() != nu {
1717            ogeom_bail!(
1718                Construction,
1719                "D{de}: the ruled surface's curves refine to different nets"
1720            );
1721        }
1722        let mut points: Vec<Weighted<Point>> = Vec::with_capacity(nu * 2);
1723        for i in 0..nu {
1724            points.push(a.control_points()[i]);
1725            points.push(b.control_points()[i]);
1726        }
1727        let grid = ControlGrid::new(points, nu, 2)?;
1728        let across = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
1729        Ok(BSplineSurface::rational(a.knots().clone(), across, grid)?.into())
1730    }
1731
1732    /// Parametric spline surface (114): a grid of bicubic polynomial
1733    /// patches, each over its own span of break points, its sixteen
1734    /// coefficients per coordinate multiplying `s^m t^l` at index `m + 4l`
1735    /// with `s` and `t` measured from the patch's first break points. Each
1736    /// patch is converted to Bézier form exactly (its monomials scaled to
1737    /// the unit square, then to the Bernstein basis both ways) and the
1738    /// patches joined on shared rows, every break point a knot of
1739    /// multiplicity three: the spline curve's (112) construction, both ways.
1740    /// After each row of patches the file carries one arbitrary patch, and
1741    /// after the last row one arbitrary row; both are skipped.
1742    fn spline_surface(&mut self, de: i64, entity: &Entity) -> OgeomResult<SurfaceGeometry> {
1743        let m = entity.count(2);
1744        let n = entity.count(3);
1745        if m == 0 || n == 0 {
1746            ogeom_bail!(Construction, "D{de}: a spline surface with no patches");
1747        }
1748        let scale = self.report.scale_mm;
1749        let tu = |i: usize| entity.at(4 + i).real();
1750        let tv = |j: usize| entity.at(4 + m + 1 + j).real();
1751        let base = 4 + (m + 1) + (n + 1);
1752        let (rows, columns) = (3 * m + 1, 3 * n + 1);
1753        let mut net = vec![Point::ORIGIN; rows * columns];
1754        // Monomial coefficients on [0, 1] to Bernstein control values:
1755        // `p_r = Σ_{k ≤ r} C(r, k) / C(3, k) · b_k`.
1756        const TO_BERNSTEIN: [[f64; 4]; 4] = [
1757            [1.0, 0.0, 0.0, 0.0],
1758            [1.0, 1.0 / 3.0, 0.0, 0.0],
1759            [1.0, 2.0 / 3.0, 1.0 / 3.0, 0.0],
1760            [1.0, 1.0, 1.0, 1.0],
1761        ];
1762        for i in 0..m {
1763            let h = tu(i + 1) - tu(i);
1764            for j in 0..n {
1765                let k = tv(j + 1) - tv(j);
1766                if h <= 0.0 || k <= 0.0 {
1767                    ogeom_bail!(
1768                        Construction,
1769                        "D{de}: spline surface patch ({i}, {j}) has no span"
1770                    );
1771                }
1772                let at = base + (i * (n + 1) + j) * 48;
1773                let mut coords = [[[0.0_f64; 4]; 4]; 3];
1774                for (axis, block) in coords.iter_mut().enumerate() {
1775                    // b[mu][lv]: the coefficient of σ^mu τ^lv on the unit square.
1776                    let mut b = [[0.0_f64; 4]; 4];
1777                    for (mu, row) in b.iter_mut().enumerate() {
1778                        for (lv, value) in row.iter_mut().enumerate() {
1779                            let raw = entity.at(at + 16 * axis + mu + 4 * lv).real();
1780                            #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
1781                            let scaled = raw * h.powi(mu as i32) * k.powi(lv as i32);
1782                            *value = scaled;
1783                        }
1784                    }
1785                    // Bernstein in s, then in t.
1786                    let mut q = [[0.0_f64; 4]; 4];
1787                    for r in 0..4 {
1788                        for lv in 0..4 {
1789                            q[r][lv] = (0..4).map(|mu| TO_BERNSTEIN[r][mu] * b[mu][lv]).sum();
1790                        }
1791                    }
1792                    for r in 0..4 {
1793                        for c in 0..4 {
1794                            block[r][c] = (0..4).map(|lv| TO_BERNSTEIN[c][lv] * q[r][lv]).sum();
1795                        }
1796                    }
1797                }
1798                for r in 0..4 {
1799                    for c in 0..4 {
1800                        net[(3 * i + r) * columns + 3 * j + c] = Point::new(
1801                            coords[0][r][c] * scale,
1802                            coords[1][r][c] * scale,
1803                            coords[2][r][c] * scale,
1804                        );
1805                    }
1806                }
1807            }
1808        }
1809        let knots = |count: usize, at: &dyn Fn(usize) -> f64| -> Vec<f64> {
1810            let mut out = vec![at(0); 4];
1811            for i in 1..count {
1812                out.extend([at(i); 3]);
1813            }
1814            out.extend([at(count); 4]);
1815            out
1816        };
1817        let grid = ControlGrid::new(
1818            net.into_iter()
1819                .map(|p| Weighted::new(p, 1.0, self.tol))
1820                .collect::<OgeomResult<Vec<_>>>()?,
1821            rows,
1822            columns,
1823        )?;
1824        Ok(ogeom_geom::BSplineSurface::rational(
1825            KnotVector::new(knots(m, &tu), 3)?,
1826            KnotVector::new(knots(n, &tv), 3)?,
1827            grid,
1828        )?
1829        .into())
1830    }
1831
1832    fn nurbs_surface(&mut self, de: i64, entity: &Entity) -> OgeomResult<SurfaceGeometry> {
1833        let k1 = entity.count(0);
1834        let k2 = entity.count(1);
1835        let m1 = entity.count(2);
1836        let m2 = entity.count(3);
1837        if m1 == 0 || m2 == 0 {
1838            ogeom_bail!(Construction, "D{de}: a B-spline surface of degree zero");
1839        }
1840        let (nu, nv) = (k1 + 1, k2 + 1);
1841        if nu
1842            .checked_mul(nv)
1843            .is_none_or(|cells| cells > entity.params.len())
1844        {
1845            ogeom_bail!(
1846                Construction,
1847                "D{de}: a {nu} by {nv} control grid in {} parameters",
1848                entity.params.len()
1849            );
1850        }
1851        let (nku, nkv) = (nu + m1 + 1, nv + m2 + 1);
1852        let base = 9;
1853        let u_knots: Vec<f64> = (0..nku).map(|i| entity.at(base + i).real()).collect();
1854        let v_knots: Vec<f64> = (0..nkv).map(|i| entity.at(base + nku + i).real()).collect();
1855        let w_base = base + nku + nkv;
1856        let p_base = w_base + nu * nv;
1857        let scale = self.report.scale_mm;
1858        // The file lists control points with the first (u) index varying
1859        // fastest; the grid stores row-major with u as the slow index, so
1860        // the read reorders.
1861        let raw = |u: usize, v: usize| -> OgeomResult<Weighted<Point>> {
1862            let i = v * nu + u;
1863            let w = entity.at(w_base + i).real();
1864            let p = Point::new(
1865                entity.at(p_base + 3 * i).real() * scale,
1866                entity.at(p_base + 3 * i + 1).real() * scale,
1867                entity.at(p_base + 3 * i + 2).real() * scale,
1868            );
1869            Weighted::new(p, w, self.tol)
1870        };
1871        let mut weighted = Vec::with_capacity(nu * nv);
1872        for u in 0..nu {
1873            for v in 0..nv {
1874                weighted.push(raw(u, v)?);
1875            }
1876        }
1877        let grid = ControlGrid::new(weighted, nu, nv)?;
1878        Ok(ogeom_geom::BSplineSurface::rational(
1879            KnotVector::new(u_knots, m1)?,
1880            KnotVector::new(v_knots, m2)?,
1881            grid,
1882        )?
1883        .into())
1884    }
1885
1886    /// A point entity (116) or bare coordinate triple carrier.
1887    fn location_entity(&mut self, de: i64) -> OgeomResult<Point> {
1888        let e = self.entity(de)?;
1889        if e.kind != 116 {
1890            ogeom_bail!(
1891                Construction,
1892                "D{de}: expected a point entity, found type {}",
1893                e.kind
1894            );
1895        }
1896        Ok(self.point3(e, 0))
1897    }
1898
1899    /// A direction entity (123) as a unit vector.
1900    /// The reference direction a parameterized analytic surface names at
1901    /// `field`, where it names one: where its angle or `u` starts.
1902    fn reference_direction(
1903        &mut self,
1904        entity: &Entity,
1905        field: usize,
1906    ) -> OgeomResult<Option<Direction>> {
1907        let de = entity.at(field).int();
1908        if de == 0 || !self.file.entities.contains_key(&de) {
1909            return Ok(None);
1910        }
1911        Ok(Some(self.direction_entity(de)?))
1912    }
1913
1914    /// A frame about `axis` at `origin`, its `x` the surface's reference
1915    /// direction where it names one (squared off the axis), else any.
1916    fn framed(
1917        &mut self,
1918        origin: Point,
1919        axis: Direction,
1920        entity: &Entity,
1921        field: usize,
1922    ) -> OgeomResult<Frame> {
1923        if let Some(reference) = self.reference_direction(entity, field)? {
1924            let r = reference.vector();
1925            let square = r - axis.vector() * r.dot(axis.vector());
1926            if let Ok(x) = Direction::new(square, self.tol) {
1927                return Frame::new(origin, axis, x, self.tol);
1928            }
1929        }
1930        frame_about(origin, axis, self.tol)
1931    }
1932
1933    fn direction_entity(&mut self, de: i64) -> OgeomResult<Direction> {
1934        let e = self.entity(de)?;
1935        if e.kind != 123 {
1936            ogeom_bail!(
1937                Construction,
1938                "D{de}: expected a direction entity, found type {}",
1939                e.kind
1940            );
1941        }
1942        Direction::from_coords(e.at(0).real(), e.at(1).real(), e.at(2).real(), self.tol)
1943    }
1944
1945    /// The boundary of a trimmed face as model-space curve segments: from a
1946    /// curve-on-surface (142), a boundary entity (141), or a bare curve,
1947    /// walking composite curves flat.
1948    fn boundary_segments(&mut self, de: i64) -> OgeomResult<Vec<(Curve, (f64, f64))>> {
1949        let entity = self.entity(de)?;
1950        match entity.kind {
1951            // Curve on surface: creation, surface, parameter-space curve,
1952            // model-space curve, preference. The model-space curve is the
1953            // trim's truth unless the sending system preferred the
1954            // parameter-space one (preference 1), whose lift through the
1955            // surface then stands, the model-space curve taking over where
1956            // the lift fails or falls short of its target. Faces recompute
1957            // exact pcurves either way and say when they cannot.
1958            142 => {
1959                let (surface_de, b, c) =
1960                    (entity.at(1).int(), entity.at(2).int(), entity.at(3).int());
1961                if c == 0 {
1962                    return self.lifted_segments(de, surface_de, b);
1963                }
1964                // Both are the trim's, whichever one builds it.
1965                for representation in [b, c] {
1966                    if representation != 0 {
1967                        self.visited.insert(representation.abs(), ());
1968                    }
1969                }
1970                if entity.at(4).int() != 1 || b == 0 {
1971                    return self.curve_segments(c);
1972                }
1973                let before = self.report.warnings.len();
1974                let why = match self.lifted_segments(de, surface_de, b) {
1975                    Ok(lifted) if self.report.warnings.len() == before => return Ok(lifted),
1976                    Ok(_) => "lifted short of its target".to_owned(),
1977                    Err(e) => format!("did not lift: {e}"),
1978                };
1979                self.report.warnings.truncate(before);
1980                self.report.warnings.push(format!(
1981                    "D{de}: the preferred parameter-space trim {why}; the model-space \
1982                     curve stands in"
1983                ));
1984                self.curve_segments(c)
1985            }
1986            141 => {
1987                let n = entity.count(3);
1988                let mut out = Vec::new();
1989                let mut i = 4;
1990                for _ in 0..n {
1991                    let cptr = entity.at(i).int();
1992                    let k = entity.count(i + 2);
1993                    i += 3 + k;
1994                    // The sense flag is advisory here too: the wire builder
1995                    // chains segments by their geometry.
1996                    out.extend(self.curve_segments(cptr)?);
1997                }
1998                Ok(out)
1999            }
2000            _ => self.curve_segments(de),
2001        }
2002    }
2003
2004    /// A trim given only in the surface's parameters: each parameter-space
2005    /// segment lifted through the surface into a model-space curve, fitted
2006    /// same-parameter with the composition to a hundredth of a micron. On a
2007    /// B-spline surface the file's parameters are the surface's own; an
2008    /// analytic surface's IGES parameterization is the file's convention,
2009    /// not this kernel's, and its lift is refused by name.
2010    fn lifted_segments(
2011        &mut self,
2012        de: i64,
2013        surface_de: i64,
2014        b: i64,
2015    ) -> OgeomResult<Vec<(Curve, (f64, f64))>> {
2016        let kind = self.entity(surface_de)?.kind;
2017        let surface = self.surface(surface_de)?;
2018        let scale = self.report.scale_mm;
2019        // The file's parameters into this kernel's chart. A B-spline's are
2020        // its own; the analytic surfaces' are the format's: lengths along
2021        // a plane's axes, a cylinder's and cone's angle in degrees with a
2022        // length along the axis (a cone's along its slant), a sphere's
2023        // two angles in degrees, and a torus's pair turned: its first
2024        // round the tube, its second (measured back from a full turn)
2025        // round the axis.
2026        let half_angle = match &surface {
2027            SurfaceGeometry::Cone(c) => c.cone().half_angle(),
2028            _ => 0.0,
2029        };
2030        // A tabulated cylinder's parameters are both fractions: of its
2031        // directrix's run and of the generator. A surface of revolution's
2032        // are its generatrix's own parameter and the angle turned from the
2033        // start angle; a line's own runs over [0, 1], a spline's is its own,
2034        // an arc's and an axis-aligned ellipse's is its angle.
2035        let swept = match (kind, &surface) {
2036            (122, SurfaceGeometry::Extrusion(e)) => {
2037                let (lo, hi) = ogeom_geom::Curve3d::domain(e.curve());
2038                let extent = ogeom_geom::Surface::domain(&surface).1;
2039                Some(((lo, hi - lo), (extent.0, extent.1 - extent.0), false))
2040            }
2041            (120, SurfaceGeometry::Revolution(r)) => {
2042                let generatrix = self.entity(surface_de)?.at(1).int();
2043                let (lo, hi) = ogeom_geom::Curve3d::domain(r.curve());
2044                let generatrix = self.entity(generatrix)?;
2045                match generatrix.kind {
2046                    110 => Some(((lo, hi - lo), (0.0, 1.0), true)),
2047                    126 => Some(((0.0, 1.0), (0.0, 1.0), true)),
2048                    // An arc's parameter is the angle in its definition
2049                    // plane, from the start angle the format puts in
2050                    // [0, 2 pi); this reader's runs from naught at the
2051                    // start, in the same sense.
2052                    100 => {
2053                        let start = (generatrix.at(4).real() - generatrix.at(2).real())
2054                            .atan2(generatrix.at(3).real() - generatrix.at(1).real())
2055                            .rem_euclid(core::f64::consts::TAU);
2056                        Some(((lo - start, 1.0), (0.0, 1.0), true))
2057                    }
2058                    // An ellipse with its axes along the definition
2059                    // plane's: the format's parameter is the angle `t` of
2060                    // `(cx + a cos t, cy + b sin t)`, `a` along x and `b`
2061                    // along y whichever is longer, from the start's in
2062                    // [0, 2 pi). This reader's runs the same way from its
2063                    // start (its frame a quarter turn on where `b` is the
2064                    // longer), so the two differ by a constant.
2065                    104 => ellipse_start_angle(generatrix)
2066                        .map(|start| ((lo - start, 1.0), (0.0, 1.0), true)),
2067                    _ => None,
2068                }
2069            }
2070            _ => None,
2071        };
2072        let start_angle = if kind == 120 {
2073            self.entity(surface_de)?.at(2).real()
2074        } else {
2075            0.0
2076        };
2077        let map = |p: ogeom_math::Point2| -> Option<ogeom_math::Point2> {
2078            use ogeom_math::Point2 as P;
2079            // The parameter-space curve was read as a model-space one and
2080            // carries the unit scale on both coordinates.
2081            let (u, v) = (p.x / scale, p.y / scale);
2082            match kind {
2083                128 => Some(P::new(u, v)),
2084                190 => Some(P::new(u * scale, v * scale)),
2085                192 => Some(P::new(u.to_radians(), v * scale)),
2086                194 => Some(P::new(u.to_radians(), v * scale * half_angle.cos())),
2087                196 => Some(P::new(u.to_radians(), v.to_radians())),
2088                198 => Some(P::new((360.0 - v).to_radians(), u.to_radians())),
2089                122 => swept.map(|((u0, du), (v0, dv), _)| P::new(u0 + du * u, v0 + dv * v)),
2090                // The generatrix's parameter in `u`, the angle in `v`; the
2091                // surface was built with the start angle turned in.
2092                120 => swept.map(|((u0, du), _, _)| P::new(v - start_angle, u0 + du * u)),
2093                _ => None,
2094            }
2095        };
2096        if map(ogeom_math::Point2::new(0.0, 0.0)).is_none() {
2097            ogeom_bail!(
2098                Construction,
2099                "D{de}: a curve-on-surface carries no model-space curve, and \
2100                 its surface's parameters are the file's convention for a \
2101                 kind this reader does not translate; see docs/CAPABILITIES.md, io.iges"
2102            );
2103        }
2104        let mut out = Vec::new();
2105        for (curve, range) in self.curve_segments(b)? {
2106            // The parameter-space curve read as a model-space one carries
2107            // the unit scale; the surface's parameters do not. Every map
2108            // above is affine, so a spline's chart is the spline on its
2109            // mapped control points, weights kept: exact, corners and all.
2110            // Any other curve is fitted at its own parameters, at every
2111            // span and between the samples, and a miss is said out loud.
2112            let chart = match &curve {
2113                Curve::BSpline(spline) => {
2114                    let mut control = Vec::with_capacity(spline.control_points().len());
2115                    for c in spline.control_points() {
2116                        let p = c.point();
2117                        let Some(q) = map(ogeom_math::Point2::new(p.x, p.y)) else {
2118                            ogeom_bail!(
2119                                Construction,
2120                                "D{de}: a trim's parameters did not translate"
2121                            );
2122                        };
2123                        control.push(Weighted::new(q, c.weight, self.tol)?);
2124                    }
2125                    ogeom_geom::BSpline2d::rational(spline.knots().clone(), control)?
2126                }
2127                _ => {
2128                    const SAMPLES: usize = 200;
2129                    let target = 1e-10;
2130                    let stations = ogeom_algo::traced::stations(&curve, range, SAMPLES);
2131                    let fitted = ogeom_algo::traced::fit_traced_2d(
2132                        |t| {
2133                            let p = curve.point_at(t, self.tol)?;
2134                            map(ogeom_math::Point2::new(p.x, p.y)).ok_or_else(|| {
2135                                ogeom_core::ogeom_err!(
2136                                    Construction,
2137                                    "D{de}: a trim's parameters did not translate"
2138                                )
2139                            })
2140                        },
2141                        &stations,
2142                        3,
2143                        target,
2144                        self.tol,
2145                    )?;
2146                    if !fitted.met {
2147                        self.report.warnings.push(format!(
2148                            "D{de}: a parameter-space trim fitted to within {:.2e} of \
2149                             the file's, in the surface's parameters",
2150                            fitted.error
2151                        ));
2152                    }
2153                    fitted.curve
2154                }
2155            };
2156            // Lifted piece by piece between the chart's corners, so the
2157            // space curve turns each corner where the chart does, and the
2158            // pieces joined back into one curve.
2159            let corners = ogeom_algo::traced::corners_2d(&chart, range);
2160            let lifted = Curve::OnSurface(Box::new(ogeom_geom::CurveOnSurface::new(
2161                chart.into(),
2162                surface.clone(),
2163            )));
2164            let target = self.tol.confusion() * 100.0;
2165            let mut cuts = vec![range.0.min(range.1)];
2166            cuts.extend(corners);
2167            cuts.push(range.0.max(range.1));
2168            let mut pieces = Vec::with_capacity(cuts.len() - 1);
2169            let mut error = 0.0_f64;
2170            for pair in cuts.windows(2) {
2171                let piece = lifted.fitted_bspline_over((pair[0], pair[1]), target, self.tol)?;
2172                error = error.max(piece.error);
2173                pieces.push(piece.curve);
2174            }
2175            let fitted = if pieces.len() == 1 {
2176                ogeom_geom::fit::Fitted {
2177                    curve: pieces.remove(0),
2178                    error,
2179                    met: error <= target,
2180                }
2181            } else {
2182                let (curve, moved) = ogeom_algo::traced::joined(&pieces, self.tol)?;
2183                let error = error + moved;
2184                ogeom_geom::fit::Fitted {
2185                    curve,
2186                    error,
2187                    met: error <= target,
2188                }
2189            };
2190            if !fitted.met {
2191                self.report.warnings.push(format!(
2192                    "D{de}: a parameter-space trim lifted to within {:.2e}",
2193                    fitted.error
2194                ));
2195            }
2196            let domain = ogeom_geom::Curve3d::domain(&fitted.curve);
2197            out.push((Curve::from(fitted.curve), domain));
2198        }
2199        Ok(out)
2200    }
2201
2202    fn curve_segments(&mut self, de: i64) -> OgeomResult<Vec<(Curve, (f64, f64))>> {
2203        self.nested(|reader| reader.curve_segments_at(de))
2204    }
2205
2206    fn curve_segments_at(&mut self, de: i64) -> OgeomResult<Vec<(Curve, (f64, f64))>> {
2207        let entity = self.entity(de)?;
2208        if entity.kind == 102 {
2209            // The composite's matrix carries its constituents on into
2210            // model space after their own, those it [`carried`]; a point or
2211            // connect point among them marks where two pieces meet and adds
2212            // no segment.
2213            let placement = self.placement(entity)?;
2214            let n = entity.count(0);
2215            let mut out = Vec::new();
2216            for i in 0..n {
2217                let member = entity.at(1 + i).int();
2218                let child = self.entity(member)?;
2219                if matches!(child.kind, 116 | 132) {
2220                    continue;
2221                }
2222                let moves = carried(child);
2223                let segments = self.curve_segments(member)?;
2224                if moves && placement != Transform::IDENTITY {
2225                    for (curve, range) in segments {
2226                        out.push((curve.transformed(&placement, self.tol)?, range));
2227                    }
2228                } else {
2229                    out.extend(segments);
2230                }
2231            }
2232            return Ok(out);
2233        }
2234        let path = entity.kind == 106;
2235        let (curve, range) = self.curve(de)?;
2236        // A copious-data path is its segments, each its own straight piece:
2237        // a trim's corners are where its edges meet, not kinks inside one.
2238        if path && let Curve::BSpline(polyline) = &curve {
2239            let mut out = Vec::new();
2240            for pair in polyline.control_points().windows(2) {
2241                let piece = BSplineCurve::new(
2242                    KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?,
2243                    vec![pair[0].point(), pair[1].point()],
2244                    self.tol,
2245                )?;
2246                out.push((Curve::from(piece), (0.0, 1.0)));
2247            }
2248            return Ok(out);
2249        }
2250        Ok(vec![(curve, range)])
2251    }
2252
2253    /// An independent curve as a wire: its segments in the file's order,
2254    /// each run the way the chain goes, consecutive segments sharing the
2255    /// vertex where they meet, and a chain that comes back to its start
2256    /// closing on its first vertex.
2257    fn wireframe(&mut self, de: i64) -> OgeomResult<Shape> {
2258        let segments = self.curve_segments(de)?;
2259        if segments.is_empty() {
2260            ogeom_bail!(Construction, "D{de}: a composite curve with no curves");
2261        }
2262        let ends: Vec<(Point, Point)> = segments
2263            .iter()
2264            .map(|(c, r)| Ok((c.point_at(r.0, self.tol)?, c.point_at(r.1, self.tol)?)))
2265            .collect::<OgeomResult<_>>()?;
2266        let weld = self.tol.confusion() * 100.0;
2267        // Each segment's sense along the chain from `start`, or how many
2268        // segments the chain reaches before a gap.
2269        let walk = |start: Point| -> Result<Vec<bool>, usize> {
2270            let mut at = start;
2271            let mut senses = Vec::with_capacity(ends.len());
2272            for (i, &(s, e)) in ends.iter().enumerate() {
2273                let forward = at.distance(s) <= at.distance(e);
2274                if at.distance(if forward { s } else { e }) > weld {
2275                    return Err(i);
2276                }
2277                senses.push(forward);
2278                at = if forward { e } else { s };
2279            }
2280            Ok(senses)
2281        };
2282        let (start, senses) = match (walk(ends[0].0), walk(ends[0].1)) {
2283            (Ok(senses), _) => (ends[0].0, senses),
2284            (_, Ok(senses)) => (ends[0].1, senses),
2285            (Err(a), Err(b)) => ogeom_bail!(
2286                Construction,
2287                "D{de}: curve segment {} starts away from where the previous one ended",
2288                a.max(b)
2289            ),
2290        };
2291        let finish = match senses.last() {
2292            Some(true) => ends[ends.len() - 1].1,
2293            _ => ends[ends.len() - 1].0,
2294        };
2295        let closed = finish.distance(start) <= weld;
2296        let head = make_vertex(&mut self.model, start).shape;
2297        let mut at_vertex = head.clone();
2298        let mut edges = Vec::with_capacity(segments.len());
2299        let n = segments.len();
2300        for (i, (((curve, range), (s, e)), forward)) in
2301            segments.into_iter().zip(ends).zip(senses).enumerate()
2302        {
2303            let next_vertex = if i + 1 == n && closed {
2304                head.clone()
2305            } else {
2306                make_vertex(&mut self.model, if forward { e } else { s }).shape
2307            };
2308            let edge = if forward {
2309                make_edge_between(
2310                    &mut self.model,
2311                    curve,
2312                    range,
2313                    &at_vertex,
2314                    &next_vertex,
2315                    self.tol,
2316                )?
2317                .shape
2318            } else {
2319                make_edge_between(
2320                    &mut self.model,
2321                    curve,
2322                    range,
2323                    &next_vertex,
2324                    &at_vertex,
2325                    self.tol,
2326                )?
2327                .shape
2328                .reversed()
2329            };
2330            edges.push(edge);
2331            at_vertex = next_vertex;
2332        }
2333        Ok(ogeom_algo::make_wire(&mut self.model, &edges, self.tol)?.shape)
2334    }
2335
2336    /// A point (116) as a vertex where its placement puts it. The display
2337    /// symbol it may name is no part of the geometry.
2338    fn point(&mut self, de: i64) -> OgeomResult<Shape> {
2339        let entity = self.entity(de)?;
2340        let at = self.placement(entity)?.apply(self.point3(entity, 0));
2341        Ok(make_vertex(&mut self.model, at).shape)
2342    }
2343
2344    /// A trimmed (144) or bounded (143) surface as a face.
2345    fn face(&mut self, de: i64) -> OgeomResult<Shape> {
2346        let entity = self.entity(de)?;
2347        let (surface_de, boundaries): (i64, Vec<i64>) = match entity.kind {
2348            144 => {
2349                let s = entity.at(0).int();
2350                let outer_given = entity.at(1).int() == 1;
2351                let n_inner = entity.count(2);
2352                let mut bs = Vec::new();
2353                if outer_given && entity.at(3).int() != 0 {
2354                    bs.push(entity.at(3).int());
2355                }
2356                for i in 0..n_inner {
2357                    bs.push(entity.at(4 + i).int());
2358                }
2359                (s, bs)
2360            }
2361            143 => {
2362                let s = entity.at(1).int();
2363                let n = entity.count(2);
2364                (s, (0..n).map(|i| entity.at(3 + i).int()).collect())
2365            }
2366            kind => ogeom_bail!(Construction, "D{de}: type {kind} is not a trimmed surface"),
2367        };
2368        let surface = self.trimmed_basis(entity, surface_de)?;
2369        if boundaries.is_empty() {
2370            // The surface's own natural boundary: a closed or bounded
2371            // surface can stand alone as a face.
2372            return Ok(ogeom_algo::make_natural_face(&mut self.model, surface)?.shape);
2373        }
2374        let mut wires = Vec::new();
2375        for boundary in boundaries {
2376            let segments = self.boundary_segments(boundary)?;
2377            let motion = self.parent_motion(entity, boundary)?;
2378            let segments = moved_segments(segments, &motion, self.tol)?;
2379            let segments = self.cut_at_trim_poles(segments);
2380            let edges = self.wire_edges(de, segments)?;
2381            self.cover_trim_poles(&edges)?;
2382            wires.push(edges);
2383        }
2384        self.assemble_face(surface, wires)
2385    }
2386
2387    /// A bounded plane (108, form 1) as a face: the plane within its closed
2388    /// bounding curve, which the plane's matrix carries as it does the
2389    /// plane.
2390    fn bounded_plane(&mut self, de: i64) -> OgeomResult<Shape> {
2391        let entity = self.entity(de)?;
2392        let surface = self.surface(de)?;
2393        let boundary = entity.at(4).int();
2394        let segments = self.curve_segments(boundary)?;
2395        let motion = self.parent_motion(entity, boundary)?;
2396        let segments = moved_segments(segments, &motion, self.tol)?;
2397        let edges = self.wire_edges(de, segments)?;
2398        self.assemble_face(surface, vec![edges])
2399    }
2400
2401    /// The surface a trimmed (144) or bounded (143) surface bounds, carried
2402    /// by the trimmed surface's matrix as its boundaries are.
2403    fn trimmed_basis(&mut self, trimmed: &Entity, de: i64) -> OgeomResult<SurfaceGeometry> {
2404        let surface = self.surface(de)?;
2405        let motion = self.parent_motion(trimmed, de)?;
2406        if motion == Transform::IDENTITY {
2407            Ok(surface)
2408        } else {
2409            Ok(surface.transformed(&motion, self.tol)?)
2410        }
2411    }
2412
2413    /// An untrimmed surface as a face: the whole of the surface its own
2414    /// bounds enclose. An offset surface stands alone only over a base
2415    /// that does.
2416    fn untrimmed_face(&mut self, de: i64) -> OgeomResult<Shape> {
2417        let mut entity = self.entity(de)?;
2418        if is_bounded_plane(entity) {
2419            return self.bounded_plane(de);
2420        }
2421        let mut depth = 0;
2422        while entity.kind == 140 {
2423            depth += 1;
2424            if depth > MOST_NESTING {
2425                ogeom_bail!(
2426                    Construction,
2427                    "D{de}: offset surfaces refer to one another more than {MOST_NESTING} deep"
2428                );
2429            }
2430            entity = self.entity(entity.at(4).int())?;
2431            if !UNTRIMMED_KINDS.contains(&entity.kind) {
2432                ogeom_bail!(
2433                    Construction,
2434                    "D{de}: an offset of a type {} surface has no bounds of its own",
2435                    entity.kind
2436                );
2437            }
2438        }
2439        let surface = self.surface(de)?;
2440        Ok(ogeom_algo::make_natural_face(&mut self.model, surface)?.shape)
2441    }
2442
2443    /// The chart poles of the surfaces of these trimmed (144) and bounded
2444    /// (143) surfaces, one point per pole; anything unreadable is passed
2445    /// over for its face to report.
2446    fn poles_of_trims(&mut self, face_des: &[i64]) -> Vec<Point> {
2447        let mut out: Vec<Point> = Vec::new();
2448        for &de in face_des {
2449            let (trimmed, surface_de) = match self.entity(de) {
2450                Ok(e) if e.kind == 144 => (e, e.at(0).int()),
2451                Ok(e) if e.kind == 143 => (e, e.at(1).int()),
2452                _ => continue,
2453            };
2454            let Ok(surface) = self.trimmed_basis(trimmed, surface_de) else {
2455                continue;
2456            };
2457            for pole in crate::pcurves::chart_poles(&surface, self.tol) {
2458                if out.iter().all(|p| p.distance(pole) > self.tol.confusion()) {
2459                    out.push(pole);
2460                }
2461            }
2462        }
2463        out
2464    }
2465
2466    /// Boundary segments cut where they run through a pole in
2467    /// `trim_poles`, each piece over its own window of the curve.
2468    ///
2469    /// No single image of a curve through a pole follows it through the
2470    /// chart: at the pole it leaves along another column than the one it
2471    /// arrived on. Trimmed surfaces share no edges, so the cut is made on
2472    /// every face's segments alike, a face whose surface has no pole there
2473    /// included: the neighbour across a sphere's meridian rim then has the
2474    /// same pieces, and the faces sew piece to piece.
2475    fn cut_at_trim_poles(&self, segments: Vec<(Curve, (f64, f64))>) -> Vec<(Curve, (f64, f64))> {
2476        if self.trim_poles.is_empty() {
2477            return segments;
2478        }
2479        let mut out = Vec::with_capacity(segments.len());
2480        for (curve, range) in segments {
2481            let crossings =
2482                crate::pcurves::pole_crossings(&curve, range, &self.trim_poles, self.tol);
2483            let mut from = range.0;
2484            for (t, ..) in crossings {
2485                out.push((curve.clone(), (from, t)));
2486                from = t;
2487            }
2488            out.push((curve, (from, range.1)));
2489        }
2490        out
2491    }
2492
2493    /// Widen each vertex of `edges` that stands near a pole in
2494    /// `trim_poles`, off it by more than the confusion distance, to reach
2495    /// the pole: the vertex a cut put on the curve is the pole's.
2496    fn cover_trim_poles(&mut self, edges: &[Shape]) -> OgeomResult<()> {
2497        let reach = self.tol.confusion() * 100.0;
2498        for edge in edges {
2499            let Some((first, last)) = ogeom_algo::edge_vertices(&self.model, edge)? else {
2500                continue;
2501            };
2502            for vertex in [first, last] {
2503                let Some(at) = self
2504                    .model
2505                    .node(&vertex)
2506                    .and_then(|n| n.data().as_vertex())
2507                    .map(|d| d.point)
2508                else {
2509                    continue;
2510                };
2511                let off = self
2512                    .trim_poles
2513                    .iter()
2514                    .map(|p| p.distance(at))
2515                    .fold(f64::INFINITY, f64::min);
2516                if off > self.tol.confusion() && off <= reach {
2517                    self.model.widen(
2518                        &vertex,
2519                        ogeom_core::Tolerance::new(off + self.tol.confusion())?,
2520                    )?;
2521                }
2522            }
2523        }
2524        Ok(())
2525    }
2526
2527    /// Boundary segments into a closed chain of edges, head to tail.
2528    ///
2529    /// Surface files are loose about sense (a boundary's segments arrive in
2530    /// order but each may run either way, the first included), so the chain
2531    /// is stitched by geometry: the walk starts from either end of the first
2532    /// segment, each later segment joins whichever of its ends sits at the
2533    /// chain's current head, and the last vertex is the first, which is what
2534    /// closes the wire. The start whose walk closes is taken.
2535    fn wire_edges(
2536        &mut self,
2537        face_de: i64,
2538        segments: Vec<(Curve, (f64, f64))>,
2539    ) -> OgeomResult<Vec<Shape>> {
2540        if segments.is_empty() {
2541            ogeom_bail!(Construction, "D{face_de}: a boundary with no curves");
2542        }
2543        let ends: Vec<(Point, Point)> = segments
2544            .iter()
2545            .map(|(c, r)| Ok((c.point_at(r.0, self.tol)?, c.point_at(r.1, self.tol)?)))
2546            .collect::<OgeomResult<_>>()?;
2547        let n = segments.len();
2548        let weld = self.tol.confusion() * 100.0;
2549
2550        // One closed segment closes on a single vertex.
2551        if n == 1 {
2552            let (curve, range) = segments
2553                .into_iter()
2554                .next()
2555                .unwrap_or_else(|| unreachable!());
2556            let (s, e) = ends[0];
2557            if s.distance(e) > weld {
2558                ogeom_bail!(
2559                    Construction,
2560                    "D{face_de}: a one-curve boundary whose ends sit {:.2e} apart",
2561                    s.distance(e)
2562                );
2563            }
2564            let v = make_vertex(&mut self.model, s).shape;
2565            let edge = make_edge_between(&mut self.model, curve, range, &v, &v, self.tol)?.shape;
2566            return Ok(vec![edge]);
2567        }
2568
2569        // Each segment's sense along the walk from `start` and how far the
2570        // walk ends from it, or the first segment that leaves a gap and how
2571        // wide it is.
2572        let walk = |start: Point| -> Result<(Vec<bool>, f64), (usize, f64)> {
2573            let mut at = start;
2574            let mut senses = Vec::with_capacity(n);
2575            for (i, &(s, e)) in ends.iter().enumerate() {
2576                let forward = at.distance(s) <= at.distance(e);
2577                let gap = at.distance(if forward { s } else { e });
2578                if gap > weld {
2579                    return Err((i, gap));
2580                }
2581                senses.push(forward);
2582                at = if forward { e } else { s };
2583            }
2584            Ok((senses, at.distance(start)))
2585        };
2586        // The walk that closes, the forward one first; failing that, a walk
2587        // with no gap inside it, whose last edge is welded to the head.
2588        let walks = [ends[0].0, ends[0].1].map(|start| (start, walk(start)));
2589        let chosen = walks
2590            .iter()
2591            .find(|(_, w)| matches!(w, Ok((_, gap)) if *gap <= weld))
2592            .or_else(|| walks.iter().find(|(_, w)| w.is_ok()));
2593        let (start, senses) = match chosen {
2594            Some((start, Ok((senses, _)))) => (*start, senses.clone()),
2595            _ => {
2596                // Report the walk that got further.
2597                let reached = |w: &Result<(Vec<bool>, f64), (usize, f64)>| match w {
2598                    Err(failure) => *failure,
2599                    Ok(_) => (n, 0.0),
2600                };
2601                let (a, b) = (reached(&walks[0].1), reached(&walks[1].1));
2602                let (i, gap) = if b.0 > a.0 { b } else { a };
2603                ogeom_bail!(
2604                    Construction,
2605                    "D{face_de}: boundary segment {i} starts {gap:.2e} from \
2606                     where the previous one ended"
2607                );
2608            }
2609        };
2610
2611        let head = make_vertex(&mut self.model, start).shape;
2612        let mut at_vertex = head.clone();
2613        let mut edges = Vec::with_capacity(n);
2614        for (i, (((curve, range), (s, e)), forward)) in
2615            segments.into_iter().zip(ends).zip(senses).enumerate()
2616        {
2617            let this_end = if forward { e } else { s };
2618            let last = i + 1 == n;
2619            let next_vertex = if last {
2620                head.clone()
2621            } else {
2622                make_vertex(&mut self.model, this_end).shape
2623            };
2624            // The edge is built along its curve's own direction; a segment
2625            // running against the chain is used reversed, exactly as a
2626            // hand-built prism's rim edges are.
2627            let edge = if forward {
2628                make_edge_between(
2629                    &mut self.model,
2630                    curve,
2631                    range,
2632                    &at_vertex,
2633                    &next_vertex,
2634                    self.tol,
2635                )?
2636                .shape
2637            } else {
2638                make_edge_between(
2639                    &mut self.model,
2640                    curve,
2641                    range,
2642                    &next_vertex,
2643                    &at_vertex,
2644                    self.tol,
2645                )?
2646                .shape
2647                .reversed()
2648            };
2649            edges.push(edge);
2650            at_vertex = next_vertex;
2651        }
2652        Ok(edges)
2653    }
2654
2655    /// A face from a surface and wires of already-built edges: the exact
2656    /// pcurve where the pair has a closed form, the fitted one where it does
2657    /// not, and both sides of the chart for an edge the wire uses twice,
2658    /// which is what a seam is.
2659    fn assemble_face(
2660        &mut self,
2661        surface: SurfaceGeometry,
2662        wires: Vec<Vec<Shape>>,
2663    ) -> OgeomResult<Shape> {
2664        let surface_id = self.model.geometry_mut().add_surface(surface.clone());
2665        let mut wire_shapes = Vec::with_capacity(wires.len());
2666        for edges in &wires {
2667            self.chart_wire(edges, &surface, surface_id)?;
2668            wire_shapes.push(ogeom_algo::make_wire(&mut self.model, edges, self.tol)?.shape);
2669        }
2670        // Every image on one branch, then a boundary meeting a pole from two
2671        // sides of the chart closed across the pole row.
2672        ogeom_algo::chain_wire_branches(&mut self.model, surface_id, &wire_shapes, self.tol)?;
2673        for wire in &mut wire_shapes {
2674            let edges = self.model.ordered_children_of(wire)?;
2675            let closed =
2676                ogeom_algo::closed_at_poles(&mut self.model, surface_id, &edges, self.tol)?;
2677            if closed.len() != edges.len() {
2678                *wire = ogeom_algo::make_wire(&mut self.model, &closed, self.tol)?.shape;
2679            }
2680        }
2681        Ok(ogeom_algo::make_face_on(&mut self.model, surface_id, &wire_shapes, self.tol)?.shape)
2682    }
2683
2684    /// One wire's pcurves, chained around the face's chart.
2685    ///
2686    /// Each edge's image is computed on its own (the exact projection where
2687    /// the pair has a closed form, the fitted one where it does not), and a
2688    /// periodic chart then has a branch to choose. Read one edge at a time
2689    /// the choice is arbitrary, and the wire comes apart: a bore's wall
2690    /// arrives with one rim written over `[−π, π]` and the other over
2691    /// `[−π/2, 3π/2]`, both the right circle and neither meeting the seam
2692    /// the wire closes on. So each image after the first is shifted by whole
2693    /// periods until its start meets where the last one ended, which is the
2694    /// rule `make_face_with_pcurves` follows for shapes this kernel builds
2695    /// itself.
2696    ///
2697    /// A seam falls out of the same walk. The wire uses it twice, up one
2698    /// column of the chart and down the other, and those columns *are* one
2699    /// image a period apart, so the chaining produces both, and the use
2700    /// that runs forward is the forward side.
2701    fn chart_wire(
2702        &mut self,
2703        edges: &[Shape],
2704        surface: &SurfaceGeometry,
2705        surface_id: ogeom_topo::SurfaceId,
2706    ) -> OgeomResult<()> {
2707        use ogeom_geom::Curve2d as _;
2708        let mut counts: FastMap<ogeom_topo::TShapeId, usize> = FastMap::default();
2709        for edge in edges {
2710            *counts.entry(edge.node()).or_default() += 1;
2711        }
2712        // One image per edge, however many times the wire walks it.
2713        let mut images: FastMap<ogeom_topo::TShapeId, (ogeom_geom::PlanarCurve, (f64, f64))> =
2714            FastMap::default();
2715        // The sides each walk of it left, by the direction that walk ran.
2716        let mut sides: FastMap<
2717            ogeom_topo::TShapeId,
2718            (
2719                Option<ogeom_geom::PlanarCurve>,
2720                Option<ogeom_geom::PlanarCurve>,
2721            ),
2722        > = FastMap::default();
2723        let mut order: Vec<ogeom_topo::TShapeId> = Vec::new();
2724        let mut previous: Option<ogeom_math::Point2> = None;
2725        let mut opening: Option<(ogeom_topo::TShapeId, bool, f64)> = None;
2726        for edge in edges {
2727            let (curve, range) = {
2728                let Some(data) = self.model.node(edge).and_then(|n| n.data().as_edge()) else {
2729                    continue;
2730                };
2731                let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d()
2732                else {
2733                    continue;
2734                };
2735                let Some(geometry) = self.model.geometry().curve(*curve) else {
2736                    continue;
2737                };
2738                (geometry.clone(), *range)
2739            };
2740            if let ogeom_core::collections::hash_map::Entry::Vacant(slot) =
2741                images.entry(edge.node())
2742            {
2743                let Some(image) = self.image_of(edge, &curve, range, surface)? else {
2744                    continue;
2745                };
2746                slot.insert((image, range));
2747                order.push(edge.node());
2748            }
2749            let Some((image, range)) = images.get(&edge.node()).cloned() else {
2750                continue;
2751            };
2752            let backwards = edge.orientation() == ogeom_topo::Orientation::Reversed;
2753            let (start, end) = if backwards {
2754                (range.1, range.0)
2755            } else {
2756                (range.0, range.1)
2757            };
2758            if previous.is_none() && counts.get(&edge.node()).copied().unwrap_or(0) == 1 {
2759                opening = Some((edge.node(), backwards, start));
2760            }
2761            let mut image =
2762                crate::pcurves::shifted_to_meet(&image, start, previous, surface, self.tol)?;
2763            let walked = sides.entry(edge.node()).or_default();
2764            let first = if backwards { &walked.0 } else { &walked.1 };
2765            if counts.get(&edge.node()).copied().unwrap_or(0) > 1
2766                && let Some(first) = first
2767            {
2768                image = crate::pcurves::across_from(image, first, range, surface, self.tol)?;
2769            }
2770            previous = Some(image.point_at(end, self.tol)?);
2771            if backwards {
2772                walked.1 = Some(image);
2773            } else {
2774                walked.0 = Some(image);
2775            }
2776        }
2777        // The first image, which no edge before it placed, meets the last
2778        // across a closing line it lies on.
2779        if let (Some((node, backwards, start)), Some(end)) = (opening, previous)
2780            && let Some(walked) = sides.get_mut(&node)
2781        {
2782            let side = if backwards {
2783                &mut walked.1
2784            } else {
2785                &mut walked.0
2786            };
2787            if let Some(image) = side.as_ref() {
2788                *side = Some(crate::pcurves::onto_closing_side(
2789                    image, start, end, surface, self.tol,
2790                )?);
2791            }
2792        }
2793        for node in order {
2794            let Some((image, range)) = images.get(&node).cloned() else {
2795                continue;
2796            };
2797            let Some(edge) = edges.iter().find(|e| e.node() == node) else {
2798                continue;
2799            };
2800            let walked = sides.remove(&node).unwrap_or((None, None));
2801            let seam = counts.get(&node).copied().unwrap_or(0) > 1;
2802            let columns = match &walked {
2803                (Some(forward), Some(reversed)) => {
2804                    let at = forward.point_at(range.0, self.tol)?;
2805                    let other = reversed.point_at(range.0, self.tol)?;
2806                    (at.distance(other) > self.tol.confusion()).then_some((forward, reversed))
2807                }
2808                _ => None,
2809            };
2810            match (seam, columns) {
2811                (true, Some((forward, reversed))) => {
2812                    ogeom_algo::attach_seam(
2813                        &mut self.model,
2814                        edge,
2815                        forward.clone(),
2816                        reversed.clone(),
2817                        surface_id,
2818                        ogeom_topo::Location::identity(),
2819                        range,
2820                    )?;
2821                }
2822                // The walk left the seam's two uses in one place: the chart
2823                // closes without being periodic (a skinned wall's is such a
2824                // chart, clamped and closed), and there is no period to
2825                // shift by. The other column goes a chart's width over,
2826                // toward the middle.
2827                (true, None) => {
2828                    let other = crate::pcurves::seam_other_side(&image, range, surface, self.tol)?;
2829                    ogeom_algo::attach_seam(
2830                        &mut self.model,
2831                        edge,
2832                        image,
2833                        other,
2834                        surface_id,
2835                        ogeom_topo::Location::identity(),
2836                        range,
2837                    )?;
2838                }
2839                (false, _) => {
2840                    let (forward, reversed) = walked;
2841                    let backwards = edge.orientation() == ogeom_topo::Orientation::Reversed;
2842                    let placed = crate::pcurves::onto_inner_column(
2843                        &forward.or(reversed).unwrap_or(image),
2844                        range,
2845                        backwards,
2846                        surface,
2847                        self.tol,
2848                    )?;
2849                    ogeom_algo::attach_pcurve(
2850                        &mut self.model,
2851                        edge,
2852                        placed,
2853                        surface_id,
2854                        ogeom_topo::Location::identity(),
2855                        range,
2856                    )?;
2857                }
2858            }
2859        }
2860        Ok(())
2861    }
2862
2863    /// One edge's image on one surface, exact where the pair has a closed
2864    /// form and fitted where it does not: the same policy the STEP reader
2865    /// applies, through the shared machinery. Where the wire puts it on a
2866    /// periodic chart is [`Self::chart_wire`]'s business.
2867    fn image_of(
2868        &mut self,
2869        edge: &Shape,
2870        curve: &Curve,
2871        range: (f64, f64),
2872        surface: &SurfaceGeometry,
2873    ) -> OgeomResult<Option<ogeom_geom::PlanarCurve>> {
2874        let prepared = self.face_de.and_then(|face| {
2875            let key = (face, range.0.to_bits(), range.1.to_bits());
2876            let slot = self.images.get_mut(&key)?;
2877            let at = slot.iter().position(|(c, _)| c == curve)?;
2878            Some(slot.swap_remove(at).1)
2879        });
2880        let (found, effects) =
2881            prepared.unwrap_or_else(|| derive_image(curve, range, surface, self.tol));
2882        for effect in effects {
2883            match effect {
2884                Effect::Warn(warning) => self.report.warnings.push(warning),
2885                Effect::Gap(off) => self.state_gap(edge, off)?,
2886            }
2887        }
2888        Ok(found)
2889    }
2890
2891    /// Derive, in parallel, the image of every edge of these faces (510)
2892    /// on its face's surface, for [`Self::image_of`] to take as the walk
2893    /// reaches it.
2894    ///
2895    /// Deriving one is a pure function of a curve, its range and a
2896    /// surface, and it is most of the time spent building faces. What is
2897    /// read to find the jobs leaves no trace: an edge not yet built is
2898    /// resolved from the file without building it, and the warnings and
2899    /// visits that reading makes are dropped, to be made again by the walk
2900    /// in its own order. Best-effort: what this cannot resolve, or
2901    /// resolves to another curve than the walk finds, the walk derives.
2902    fn prepare_images(&mut self, face_des: &[i64]) {
2903        let warned = self.report.warnings.len();
2904        let visited = self.visited.clone();
2905        let mut surfaces: Vec<SurfaceGeometry> = Vec::new();
2906        let mut jobs: Vec<(i64, usize, Curve, (f64, f64))> = Vec::new();
2907        let mut seen: ogeom_core::FastSet<(i64, i64, i64)> = ogeom_core::FastSet::default();
2908        for &face_de in face_des {
2909            let Ok(entity) = self.entity(face_de) else {
2910                continue;
2911            };
2912            if entity.kind != 510 {
2913                continue;
2914            }
2915            let Ok(surface) = self.surface(entity.at(0).int()) else {
2916                continue;
2917            };
2918            surfaces.push(surface);
2919            let at = surfaces.len() - 1;
2920            for i in 0..entity.count(1) {
2921                let Ok(entries) = self.loop_entries(entity.at(3 + i).int()) else {
2922                    continue;
2923                };
2924                for (list_de, index, _) in entries.into_iter().flatten() {
2925                    if !seen.insert((face_de, list_de, index)) {
2926                        continue;
2927                    }
2928                    for (curve, range) in self.edge_geometry(list_de, index) {
2929                        jobs.push((face_de, at, curve, range));
2930                    }
2931                }
2932            }
2933        }
2934        self.report.warnings.truncate(warned);
2935        self.visited = visited;
2936        // Below a handful of edges the threads cost more than the work.
2937        if jobs.len() < 16 {
2938            return;
2939        }
2940        let tol = self.tol;
2941        let derived = ogeom_core::parallel::map_ordered(&jobs, |_, (_, at, curve, range)| {
2942            derive_image(curve, *range, &surfaces[*at], tol)
2943        });
2944        for ((face, _, curve, range), image) in jobs.into_iter().zip(derived) {
2945            self.images
2946                .entry((face, range.0.to_bits(), range.1.to_bits()))
2947                .or_default()
2948                .push((curve, image));
2949        }
2950    }
2951
2952    /// The curves and ranges edge `index` of an edge list (504) is built
2953    /// on: its pieces' where it was cut at poles, its own where it is
2954    /// built, and otherwise as [`Self::list_edge`] would build it. Empty
2955    /// where the file cannot say.
2956    fn edge_geometry(&mut self, list_de: i64, index: i64) -> Vec<(Curve, (f64, f64))> {
2957        let key = (list_de, index);
2958        let built: Vec<Shape> = match (self.pieces.get(&key), self.edges.get(&key)) {
2959            (Some(pieces), _) => pieces.clone(),
2960            (None, Some((edge, ..))) => vec![edge.clone()],
2961            (None, None) => {
2962                return self
2963                    .edge_on_file(list_de, index)
2964                    .map(|(curve, range, ..)| vec![(curve, range)])
2965                    .unwrap_or_default();
2966            }
2967        };
2968        built
2969            .iter()
2970            .filter_map(|edge| {
2971                let data = self.model.node(edge)?.data().as_edge()?;
2972                let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d()
2973                else {
2974                    return None;
2975                };
2976                Some((self.model.geometry().curve(*curve)?.clone(), *range))
2977            })
2978            .collect()
2979    }
2980
2981    /// Widen `edge` and its vertices to cover a gap it was measured to
2982    /// stand from a face it bounds. Past the millimetre a file's slop stays
2983    /// under, the pcurve is a fit gone astray (a curve through a pole left
2984    /// whole, whose image leaps across the chart there), and an edge that
2985    /// wide would swallow its neighbours; it is reported instead.
2986    fn state_gap(&mut self, edge: &Shape, off: f64) -> OgeomResult<()> {
2987        if off > self.tol.confusion() * 1e7 {
2988            self.report.warnings.push(format!(
2989                "a projected pcurve fit stopped at {off:.2e}; \
2990                 the face's mesh may sit that far off along this edge"
2991            ));
2992        } else if off > self.tol.confusion() {
2993            self.model.widen(
2994                edge,
2995                ogeom_core::Tolerance::new(off + self.tol.confusion())?,
2996            )?;
2997        }
2998        Ok(())
2999    }
3000
3001    /// A manifold solid B-rep object: shell of faces of loops of edges.
3002    fn manifold_solid(&mut self, de: i64) -> OgeomResult<Shape> {
3003        let entity = self.entity(de)?;
3004        // Each shell comes with a flag saying whether it faces as its faces
3005        // do; a void's shell faces out of the cavity and is used the other
3006        // way round, so every face of the solid faces away from the material.
3007        // An absent flag agrees.
3008        let agrees = |flag: &crate::iges::parse::Value| {
3009            matches!(flag, crate::iges::parse::Value::Default) || flag.int() != 0
3010        };
3011        let used = |shell: Shape, agrees: bool| if agrees { shell } else { shell.reversed() };
3012        let shell = used(self.shell(entity.at(0).int())?, agrees(entity.at(1)));
3013        let n_voids = entity.count(2);
3014        let mut shells = vec![shell];
3015        for i in 0..n_voids {
3016            let void = self.shell(entity.at(3 + 2 * i).int())?;
3017            shells.push(used(void, agrees(entity.at(4 + 2 * i))));
3018        }
3019        let solid = make_solid(&mut self.model, &shells)?.shape;
3020        // A fitted trim widens its edge to the offset it measured; the
3021        // edge's vertices come along, and the solid keeps the containment
3022        // rule the checker holds it to.
3023        ogeom_algo::restore_containment(&mut self.model, &solid)?;
3024        Ok(solid)
3025    }
3026
3027    fn shell(&mut self, de: i64) -> OgeomResult<Shape> {
3028        self.nested(|reader| reader.shell_at(de))
3029    }
3030
3031    fn shell_at(&mut self, de: i64) -> OgeomResult<Shape> {
3032        let entity = self.entity(de)?;
3033        if entity.kind != 514 {
3034            ogeom_bail!(
3035                Construction,
3036                "D{de}: expected a shell, found type {}",
3037                entity.kind
3038            );
3039        }
3040        let n = entity.count(0);
3041        let face_des: Vec<i64> = (0..n).map(|i| entity.at(1 + 2 * i).int()).collect();
3042        self.cut_at_poles(&face_des);
3043        self.prepare_images(&face_des);
3044        let mut faces = Vec::with_capacity(n);
3045        for i in 0..n {
3046            let face_de = entity.at(1 + 2 * i).int();
3047            let same_sense = entity.at(2 + 2 * i).int() != 0;
3048            let face = self.brep_face(face_de)?;
3049            faces.push(if same_sense { face } else { face.reversed() });
3050        }
3051        self.images.clear();
3052        Ok(ogeom_algo::make_shell(&mut self.model, &faces)?.shape)
3053    }
3054
3055    fn brep_face(&mut self, de: i64) -> OgeomResult<Shape> {
3056        let entity = self.entity(de)?;
3057        if entity.kind != 510 {
3058            ogeom_bail!(
3059                Construction,
3060                "D{de}: expected a face, found type {}",
3061                entity.kind
3062            );
3063        }
3064        let surface = self.surface(entity.at(0).int())?;
3065        let n_loops = entity.count(1);
3066        // Parameter 2 is the outer-loop flag; the loop pointers follow.
3067        let mut wires = Vec::with_capacity(n_loops);
3068        for i in 0..n_loops {
3069            wires.push(self.loop_edges(entity.at(3 + i).int())?);
3070        }
3071        let outer = self.face_de.replace(de);
3072        let face = self.assemble_face(surface, wires);
3073        self.face_de = outer;
3074        face
3075    }
3076
3077    /// A loop's (508) entries in order: for an edge, its list, its index
3078    /// and whether the loop walks it along its curve; `None` for a vertex.
3079    fn loop_entries(&mut self, de: i64) -> OgeomResult<Vec<Option<(i64, i64, bool)>>> {
3080        let entity = self.entity(de)?;
3081        if entity.kind != 508 {
3082            ogeom_bail!(
3083                Construction,
3084                "D{de}: expected a loop, found type {}",
3085                entity.kind
3086            );
3087        }
3088        let n = entity.count(0);
3089        let mut entries = Vec::with_capacity(n);
3090        let mut i = 1;
3091        for _ in 0..n {
3092            let is_vertex = entity.at(i).int() == 1;
3093            let list_de = entity.at(i + 1).int();
3094            let index = entity.at(i + 2).int();
3095            let orientation = entity.at(i + 3).int();
3096            let k = entity.count(i + 4);
3097            i += 5 + 2 * k;
3098            entries.push((!is_vertex).then_some((list_de, index, orientation != 0)));
3099        }
3100        Ok(entries)
3101    }
3102
3103    fn loop_edges(&mut self, de: i64) -> OgeomResult<Vec<Shape>> {
3104        let mut edges = Vec::new();
3105        for entry in self.loop_entries(de)? {
3106            let Some((list_de, index, along)) = entry else {
3107                // A vertex entry marks a degenerate use; the face builder
3108                // rebuilds chart degeneracies from the surface itself.
3109                self.report.warnings.push(format!(
3110                    "D{de}: a loop lists a vertex entry, which this reader skips"
3111                ));
3112                continue;
3113            };
3114            let pieces = match self.pieces.get(&(list_de, index)) {
3115                Some(pieces) => pieces.clone(),
3116                None => vec![self.list_edge(list_de, index)?.0],
3117            };
3118            if along {
3119                edges.extend(pieces);
3120            } else {
3121                edges.extend(pieces.into_iter().rev().map(|p| p.reversed()));
3122            }
3123        }
3124        Ok(edges)
3125    }
3126
3127    /// Cut every edge of these faces (510) whose curve runs through a pole
3128    /// of a face it bounds (a sphere's pole, a cone's apex, a patch's
3129    /// collapsed side) at that pole, before any face is built: no single
3130    /// image of such a curve follows it through the chart, and each piece
3131    /// has one. The cut is made on the shared edge, so every face it bounds
3132    /// walks the same pieces. Best-effort: anything unreadable here is left
3133    /// whole for the face to report.
3134    fn cut_at_poles(&mut self, face_des: &[i64]) {
3135        let mut crossings: BTreeMap<(i64, i64), Vec<crate::pcurves::Crossing>> = BTreeMap::new();
3136        for &face_de in face_des {
3137            let Ok(entity) = self.entity(face_de) else {
3138                continue;
3139            };
3140            if entity.kind != 510 {
3141                continue;
3142            }
3143            let loops: Vec<i64> = (0..entity.count(1))
3144                .map(|i| entity.at(3 + i).int())
3145                .collect();
3146            let Ok(surface) = self.surface(entity.at(0).int()) else {
3147                continue;
3148            };
3149            let poles = crate::pcurves::chart_poles(&surface, self.tol);
3150            if poles.is_empty() {
3151                continue;
3152            }
3153            for loop_de in loops {
3154                let Ok(entries) = self.loop_entries(loop_de) else {
3155                    continue;
3156                };
3157                for (list_de, index, _) in entries.into_iter().flatten() {
3158                    let key = (list_de, index);
3159                    let Ok((_, curve, range)) = self.list_edge(list_de, index) else {
3160                        continue;
3161                    };
3162                    let found = crate::pcurves::pole_crossings(&curve, range, &poles, self.tol);
3163                    let entry = crossings.entry(key).or_default();
3164                    for crossing in found {
3165                        if entry.iter().all(|(t, ..)| (t - crossing.0).abs() > 1e-9) {
3166                            entry.push(crossing);
3167                        }
3168                    }
3169                }
3170            }
3171        }
3172        for (key, mut at) in crossings {
3173            if at.is_empty() || self.pieces.contains_key(&key) {
3174                continue;
3175            }
3176            at.sort_by(|x, y| x.0.total_cmp(&y.0));
3177            let Some((edge, curve, range)) = self.edges.get(&key).cloned() else {
3178                continue;
3179            };
3180            if let Ok(cut) = crate::pcurves::split_at_poles(
3181                &mut self.model,
3182                &edge,
3183                &curve,
3184                range,
3185                &at,
3186                &mut self.pole_vertices,
3187                self.tol,
3188            ) {
3189                self.pieces
3190                    .insert(key, cut.into_iter().map(|(piece, _)| piece).collect());
3191            }
3192        }
3193    }
3194
3195    /// Edge `index` (1-based) of an edge list (504), built once and shared.
3196    fn list_edge(&mut self, list_de: i64, index: i64) -> OgeomResult<BuiltEdge> {
3197        let key = (list_de, index);
3198        if let Some(found) = self.edges.get(&key) {
3199            return Ok(found.clone());
3200        }
3201        let (curve, range, start, end) = self.edge_on_file(list_de, index)?;
3202        let vs = self.list_vertex(start.0, start.1)?;
3203        let ve = self.list_vertex(end.0, end.1)?;
3204        // IGES states no tolerances, so a vertex is built at the confusion
3205        // tolerance and a curve end that misses it by rounding (a writer's
3206        // last digit, a few tenths of a nanometre) would refuse the whole
3207        // solid. As the STEP reader does, the vertex's tolerance grows to
3208        // state the miss, up to the millimetre past which a boundary is not
3209        // this curve's at all; beyond that the edge still refuses by name.
3210        let cap = self.tol.confusion() * 1e7;
3211        for (vertex, t) in [(&vs, range.0), (&ve, range.1)] {
3212            let (Ok(end), Some(stated)) = (
3213                curve.point_at(t, self.tol),
3214                self.model
3215                    .node(vertex)
3216                    .and_then(|n| n.data().as_vertex())
3217                    .map(|d| (d.point, d.tolerance)),
3218            ) else {
3219                continue;
3220            };
3221            let gap = end.distance(stated.0);
3222            if gap > stated.1.get() && gap <= cap {
3223                self.model.widen(
3224                    vertex,
3225                    ogeom_core::Tolerance::new(gap + self.tol.confusion())?,
3226                )?;
3227                self.vertex_misses.0 += 1;
3228                self.vertex_misses.1 = self.vertex_misses.1.max(gap);
3229            }
3230        }
3231        let edge =
3232            make_edge_between(&mut self.model, curve.clone(), range, &vs, &ve, self.tol)?.shape;
3233        let built = (edge, curve, range);
3234        self.edges.insert(key, built.clone());
3235        Ok(built)
3236    }
3237
3238    /// Edge `index` (1-based) of an edge list (504) as the file gives it,
3239    /// built into nothing: its curve, its range and the vertex list
3240    /// entries it starts and ends at.
3241    #[allow(clippy::type_complexity, reason = "one edge entry's parts")]
3242    fn edge_on_file(
3243        &mut self,
3244        list_de: i64,
3245        index: i64,
3246    ) -> OgeomResult<(Curve, (f64, f64), (i64, i64), (i64, i64))> {
3247        let entity = self.entity(list_de)?;
3248        if entity.kind != 504 {
3249            ogeom_bail!(
3250                Construction,
3251                "D{list_de}: expected an edge list, found type {}",
3252                entity.kind
3253            );
3254        }
3255        let i = index
3256            .checked_sub(1)
3257            .and_then(|i| usize::try_from(i).ok())
3258            .ok_or_else(|| {
3259                ogeom_core::ogeom_err!(Construction, "D{list_de}: edge index {index} out of range")
3260            })?;
3261        let base = 1 + 5 * i;
3262        let curve_de = entity.at(base).int();
3263        let start = (entity.at(base + 1).int(), entity.at(base + 2).int());
3264        let end = (entity.at(base + 3).int(), entity.at(base + 4).int());
3265        let (curve, mut range) = self.curve(curve_de)?;
3266        let ps = self.vertex_point(start.0, start.1)?;
3267        let pe = self.vertex_point(end.0, end.1)?;
3268        // Re-derive the range from the vertices on this kernel's own
3269        // parameterization, exactly as the STEP reader does and for the same
3270        // reason: the file's parameterization is its own business.
3271        if let (Some(a), Some(b)) = (parameter_on(&curve, ps), parameter_on(&curve, pe)) {
3272            let period = if curve.is_periodic() {
3273                let (lo, hi) = curve.domain();
3274                hi - lo
3275            } else {
3276                0.0
3277            };
3278            range = if ps.distance(pe) < self.tol.confusion() && period > 0.0 {
3279                (a, a + period)
3280            } else if period > 0.0 && b <= a + self.tol.parametric() {
3281                (a, b + period)
3282            } else {
3283                (a, b)
3284            };
3285            // A vertex a few nanometres past a bounded curve's end projects
3286            // past it: the range is held to the curve's own domain, and the
3287            // vertex widens below to cover the rest of the miss.
3288            if period == 0.0 {
3289                let (lo, hi) = curve.domain();
3290                range = (range.0.clamp(lo, hi), range.1.clamp(lo, hi));
3291            }
3292        }
3293        Ok((curve, range, start, end))
3294    }
3295
3296    /// Where vertex `index` (1-based) of a vertex list (502) stands.
3297    fn vertex_point(&mut self, list_de: i64, index: i64) -> OgeomResult<Point> {
3298        if let Some(found) = self.vertices.get(&(list_de, index)) {
3299            return Ok(self.point_of(found));
3300        }
3301        let entity = self.entity(list_de)?;
3302        if entity.kind != 502 {
3303            ogeom_bail!(
3304                Construction,
3305                "D{list_de}: expected a vertex list, found type {}",
3306                entity.kind
3307            );
3308        }
3309        let i = index
3310            .checked_sub(1)
3311            .and_then(|i| usize::try_from(i).ok())
3312            .ok_or_else(|| {
3313                ogeom_core::ogeom_err!(
3314                    Construction,
3315                    "D{list_de}: vertex index {index} out of range"
3316                )
3317            })?;
3318        Ok(self.point3(entity, 1 + 3 * i))
3319    }
3320
3321    /// Vertex `index` (1-based) of a vertex list (502), built once and
3322    /// shared; sharing is what lets a closed shell close.
3323    fn list_vertex(&mut self, list_de: i64, index: i64) -> OgeomResult<Shape> {
3324        let key = (list_de, index);
3325        if let Some(found) = self.vertices.get(&key) {
3326            return Ok(found.clone());
3327        }
3328        let point = self.vertex_point(list_de, index)?;
3329        let vertex = make_vertex(&mut self.model, point).shape;
3330        self.vertices.insert(key, vertex.clone());
3331        Ok(vertex)
3332    }
3333
3334    fn point_of(&self, vertex: &Shape) -> Point {
3335        self.model
3336            .node(vertex)
3337            .and_then(|n| n.data().as_vertex())
3338            .map_or(Point::ORIGIN, |d| d.point)
3339    }
3340
3341    /// The document: parts named from entity labels, colours from the fixed
3342    /// palette and from 314 entities the directory colour fields point at.
3343    /// A constructive solid entity's solids: one for a primitive, a boolean
3344    /// tree or an instance, one per item for an assembly.
3345    fn csg_solids(&mut self, de: i64) -> OgeomResult<Vec<Shape>> {
3346        let entity = self.entity(de)?;
3347        if entity.kind != 184 {
3348            return Ok(vec![self.csg(de)?]);
3349        }
3350        // Solid assembly: items, then a placement matrix for each.
3351        let n = entity.count(0);
3352        let mut out = Vec::with_capacity(n);
3353        for i in 0..n {
3354            let item = entity.at(1 + i).int();
3355            let matrix = entity.at(1 + n + i).int();
3356            let mut solid = self.csg(item)?;
3357            if matrix != 0 {
3358                let placement = {
3359                    let holder = Entity {
3360                        kind: 0,
3361                        form: 0,
3362                        transform: matrix,
3363                        colour: 0,
3364                        level: 0,
3365                        status: 0,
3366                        label: String::new(),
3367                        params: Vec::new(),
3368                    };
3369                    self.placement(&holder)?
3370                };
3371                let location = ogeom_topo::Location::of(self.model.add_datum(placement));
3372                solid = solid.moved(&location);
3373            }
3374            out.push(solid);
3375        }
3376        Ok(out)
3377    }
3378
3379    /// One constructive solid: a primitive (150 to 168), a boolean tree (180),
3380    /// a solid instance (430) or a manifold solid (186), under the entity's
3381    /// own placement.
3382    #[allow(clippy::too_many_lines, reason = "one case per primitive")]
3383    fn csg(&mut self, de: i64) -> OgeomResult<Shape> {
3384        let entity = self.entity(de)?;
3385        let s = self.report.scale_mm;
3386        let tol = self.tol;
3387        let r = |i: usize| entity.at(i).real();
3388        let length = |i: usize| entity.at(i).real() * s;
3389        let point = |i: usize| Point::new(r(i) * s, r(i + 1) * s, r(i + 2) * s);
3390        // An axis the file leaves at its default is the default axis.
3391        let direction = |i: usize, default: Vector| -> OgeomResult<Direction> {
3392            let v = Vector::new(r(i), r(i + 1), r(i + 2));
3393            Direction::new(if v.magnitude() > 0.0 { v } else { default }, tol)
3394        };
3395        let shape = match entity.kind {
3396            150 => {
3397                let frame = Frame::new(
3398                    point(3),
3399                    direction(9, Vector::Z)?,
3400                    direction(6, Vector::X)?,
3401                    tol,
3402                )?;
3403                ogeom_algo::make_box(
3404                    &mut self.model,
3405                    frame,
3406                    (length(0), length(1), length(2)),
3407                    tol,
3408                )?
3409                .shape
3410            }
3411            152 => {
3412                // The file's wedge narrows along its local y, to `LTX` along
3413                // x at y = LY; this vocabulary's narrows along its z. Its
3414                // frame is the file's turned: z along the file's y, x along
3415                // the file's z, so its y runs along the file's x.
3416                let x_file = direction(7, Vector::X)?;
3417                let z_file = direction(10, Vector::Z)?;
3418                let y_file = Direction::new(z_file.vector().cross(x_file.vector()), tol)?;
3419                let frame = Frame::new(point(4), y_file, z_file, tol)?;
3420                ogeom_algo::make_wedge(
3421                    &mut self.model,
3422                    frame,
3423                    (length(2), length(0), length(1)),
3424                    (length(2), length(3)),
3425                    tol,
3426                )?
3427                .shape
3428            }
3429            154 => {
3430                let frame = frame_about(point(2), direction(5, Vector::Z)?, tol)?;
3431                ogeom_algo::make_cylinder(&mut self.model, frame, length(1), length(0), tol)?.shape
3432            }
3433            156 => {
3434                let frame = frame_about(point(3), direction(6, Vector::Z)?, tol)?;
3435                ogeom_algo::make_cone(&mut self.model, frame, length(1), length(2), length(0), tol)?
3436                    .shape
3437            }
3438            158 => {
3439                let frame = frame_about(point(1), Direction::Z, tol)?;
3440                ogeom_algo::make_sphere(&mut self.model, frame, length(0), tol)?.shape
3441            }
3442            160 => {
3443                let frame = frame_about(point(2), direction(5, Vector::Z)?, tol)?;
3444                ogeom_algo::make_torus(&mut self.model, frame, length(0), length(1), tol)?.shape
3445            }
3446            162 => {
3447                // A planar profile turned about an axis by a fraction of a
3448                // full turn; an open profile (form 1) closed to the axis.
3449                let axis = ogeom_math::Axis::new(point(2), direction(5, Vector::Z)?);
3450                let fraction = match r(1) {
3451                    f if f > 0.0 => f.min(1.0),
3452                    _ => 1.0,
3453                };
3454                let face = self.profile_face(de, entity.at(0).int(), Some(axis))?;
3455                ogeom_algo::make_revolution(
3456                    &mut self.model,
3457                    &face,
3458                    axis,
3459                    core::f64::consts::TAU * fraction,
3460                    tol,
3461                )?
3462                .shape
3463            }
3464            164 => {
3465                let face = self.profile_face(de, entity.at(0).int(), None)?;
3466                let along = direction(2, Vector::Z)?.vector() * length(1);
3467                ogeom_algo::make_prism(&mut self.model, &face, along, tol)?.shape
3468            }
3469            168 => {
3470                // A ball of unit radius, stretched along the local axes.
3471                let x = direction(6, Vector::X)?.vector();
3472                let z = direction(9, Vector::Z)?.vector();
3473                let y = z.cross(x);
3474                let (a, b, c) = (length(0), length(1), length(2));
3475                let linear = Matrix3::new([
3476                    [x.x * a, y.x * b, z.x * c],
3477                    [x.y * a, y.y * b, z.y * c],
3478                    [x.z * a, y.z * b, z.z * c],
3479                ]);
3480                let ball = ogeom_algo::make_sphere(&mut self.model, Frame::WORLD, 1.0, tol)?.shape;
3481                let stretch = ogeom_math::GeneralTransform::new(linear, point(3).to_vector());
3482                ogeom_algo::general_transformed_shape(&mut self.model, &ball, &stretch, tol)?.shape
3483            }
3484            180 => {
3485                // Post-order: operands pushed, each operation code (1 union,
3486                // 2 intersection, 3 difference) combining the last two.
3487                let n = entity.count(0);
3488                let mut stack: Vec<Shape> = Vec::new();
3489                for i in 0..n {
3490                    let item = entity.at(1 + i).int();
3491                    if item < 0 {
3492                        stack.push(self.csg(-item)?);
3493                        continue;
3494                    }
3495                    let (Some(b), Some(a)) = (stack.pop(), stack.pop()) else {
3496                        ogeom_bail!(
3497                            Construction,
3498                            "D{de}: a boolean tree's operation has too few operands"
3499                        );
3500                    };
3501                    let result = match item {
3502                        1 => ogeom_bool::fuse(&mut self.model, &a, &b, tol)?,
3503                        2 => ogeom_bool::common(&mut self.model, &a, &b, tol)?,
3504                        3 => ogeom_bool::cut(&mut self.model, &a, &b, tol)?,
3505                        code => ogeom_bail!(
3506                            Construction,
3507                            "D{de}: boolean operation code {code} names no operation"
3508                        ),
3509                    };
3510                    stack.push(result.shape);
3511                }
3512                let [solid] = stack.as_slice() else {
3513                    ogeom_bail!(
3514                        Construction,
3515                        "D{de}: a boolean tree leaves {} results",
3516                        stack.len()
3517                    );
3518                };
3519                solid.clone()
3520            }
3521            430 => self.csg(entity.at(0).int())?,
3522            186 => self.manifold_solid(de)?,
3523            kind => ogeom_bail!(
3524                Construction,
3525                "D{de}: type {kind} is not a constructive solid"
3526            ),
3527        };
3528        let placement = self.placement(entity)?;
3529        if placement == Transform::IDENTITY {
3530            return Ok(shape);
3531        }
3532        let location = ogeom_topo::Location::of(self.model.add_datum(placement));
3533        Ok(shape.moved(&location))
3534    }
3535
3536    /// A planar face bounded by a closed curve entity, or, where the curve
3537    /// is open and an axis is given, by the curve closed to the axis.
3538    fn profile_face(
3539        &mut self,
3540        de: i64,
3541        curve: i64,
3542        axis: Option<ogeom_math::Axis>,
3543    ) -> OgeomResult<Shape> {
3544        let mut segments = self.curve_segments(curve)?;
3545        let start = segments[0].0.point_at(segments[0].1.0, self.tol)?;
3546        let last = &segments[segments.len() - 1];
3547        let end = last.0.point_at(last.1.1, self.tol)?;
3548        if start.distance(end) > self.tol.confusion() * 100.0 {
3549            let Some(axis) = axis else {
3550                ogeom_bail!(Construction, "D{de}: an extruded profile does not close");
3551            };
3552            let foot = |p: Point| {
3553                let o = axis.location;
3554                let d = axis.direction.vector();
3555                o + d * (p - o).dot(d)
3556            };
3557            let (end_foot, start_foot) = (foot(end), foot(start));
3558            for (a, b) in [(end, end_foot), (end_foot, start_foot), (start_foot, start)] {
3559                if a.distance(b) > self.tol.confusion() * 100.0 {
3560                    let line: Curve = LineCurve::segment(a, b, self.tol)?.into();
3561                    let domain = line.domain();
3562                    segments.push((line, domain));
3563                }
3564            }
3565        }
3566        let edges = self.wire_edges(de, segments)?;
3567        let wire = ogeom_algo::make_wire(&mut self.model, &edges, self.tol)?.shape;
3568        let Some(plane) = ogeom_algo::find_plane(&self.model, &wire, self.tol)? else {
3569            ogeom_bail!(Construction, "D{de}: a solid's profile is not planar");
3570        };
3571        let surface: SurfaceGeometry = PlaneSurface::over(
3572            plane,
3573            (-SURFACE_EXTENT, SURFACE_EXTENT),
3574            (-SURFACE_EXTENT, SURFACE_EXTENT),
3575        )?
3576        .into();
3577        Ok(ogeom_algo::make_face_with_pcurves(&mut self.model, surface, &[edges], self.tol)?.shape)
3578    }
3579
3580    /// Every independent subfigure instance placed: a singular instance
3581    /// (408) its definition (308), a network instance (420) its network
3582    /// definition (320), an array (412, 414) its base at each position it
3583    /// keeps. Solids are moved into place; each placement's
3584    /// faces are moved and sewn, closed shells becoming solids as a surface
3585    /// file's do; its curves are moved wires. What an instance places is
3586    /// built once, however many place it: the placements share its
3587    /// topology under their own locations.
3588    fn subfigure_instances(&mut self, tol: Tolerances) -> OgeomResult<Placed> {
3589        let instances: Vec<i64> = self
3590            .file
3591            .entities
3592            .iter()
3593            .filter(|(_, e)| INSTANCE_KINDS.contains(&e.kind) && (e.status / 10_000) % 100 == 0)
3594            .map(|(de, _)| *de)
3595            .collect();
3596        let mut built: FastMap<i64, Content> = FastMap::default();
3597        let mut placed = Placed::default();
3598        for de in instances {
3599            let copies = match self.instance(de, &mut built, 0) {
3600                Ok(found) => found,
3601                Err(e) => {
3602                    self.report
3603                        .warnings
3604                        .push(format!("D{de}: subfigure instance not placed: {e}"));
3605                    continue;
3606                }
3607            };
3608            for copy in copies {
3609                placed
3610                    .from
3611                    .extend(copy.solids.iter().map(|s| (de, s.clone())));
3612                placed
3613                    .from
3614                    .extend(copy.faces.iter().map(|f| (de, f.clone())));
3615                placed.solids.extend(copy.solids);
3616                placed.wires.extend(copy.wires);
3617                placed.points.extend(copy.points);
3618                if copy.faces.is_empty() {
3619                    continue;
3620                }
3621                let sewn = sew(&mut self.model, &copy.faces, tol)?;
3622                for shell in &sewn.shells {
3623                    ogeom_algo::join_seam_columns(&mut self.model, shell, tol)?;
3624                    if ogeom_algo::is_shell_closed(&self.model, shell)? {
3625                        placed
3626                            .solids
3627                            .push(make_solid(&mut self.model, std::slice::from_ref(shell))?.shape);
3628                    } else {
3629                        placed.sheets.push(shell.clone());
3630                    }
3631                }
3632            }
3633        }
3634        Ok(placed)
3635    }
3636
3637    /// One instance's placements of what it instances, one per position.
3638    ///
3639    /// The order is the format's (IGES 5.3, section 3.6.2): the scale about
3640    /// the instanced definition space's origin, then the instance's own
3641    /// matrix, then the position, which that matrix does not move. A
3642    /// singular instance (408) has one position, its translation, and so
3643    /// has a network instance (420), whose scale must be alike on every
3644    /// axis and which places nothing where it, or its definition where it
3645    /// leaves its type unspecified, is flagged logical (a schematic's). A
3646    /// rectangular array (412) counts its positions up the first column,
3647    /// then up the next, the grid turned about its corner; a circular
3648    /// array (414) counts them counter-clockwise round its circle from the
3649    /// start angle. Neither turns the copies themselves. A list keeps only
3650    /// the positions it names (DO) or all but those (DON'T).
3651    fn instance(
3652        &mut self,
3653        de: i64,
3654        built: &mut FastMap<i64, Content>,
3655        depth: usize,
3656    ) -> OgeomResult<Vec<Content>> {
3657        let entity = self.entity(de)?;
3658        let s = self.report.scale_mm;
3659        let at = |i: usize| entity.at(i).real() * s;
3660        let scale_at = |i: usize| match entity.at(i).real() {
3661            f if f > 0.0 => f,
3662            _ => 1.0,
3663        };
3664        // The base, the scale, and each kept position in definition space.
3665        let (base, factor, positions): (i64, f64, Vec<Vector>) = match entity.kind {
3666            408 => {
3667                let definition = entity.at(0).int();
3668                if let Some(e) = self.file.entity(definition)
3669                    && !matches!(e.kind, 308 | 416)
3670                {
3671                    ogeom_bail!(
3672                        Construction,
3673                        "D{de}: an instance names a type {} entity, not a subfigure \
3674                         definition or an external reference to one",
3675                        e.kind
3676                    );
3677                }
3678                (
3679                    definition,
3680                    scale_at(4),
3681                    vec![Vector::new(at(1), at(2), at(3))],
3682                )
3683            }
3684            412 => {
3685                let (columns, rows) = (entity.at(5).int(), entity.at(6).int());
3686                let grid = (columns >= 1 && rows >= 1)
3687                    .then(|| columns.checked_mul(rows))
3688                    .flatten();
3689                let count = array_count(de, grid)?;
3690                let kept = kept_positions(entity, 10, count);
3691                let corner = Vector::new(at(2), at(3), at(4));
3692                let (dx, dy) = (at(7), at(8));
3693                let (sin, cos) = entity.at(9).real().sin_cos();
3694                let rows = usize::try_from(rows).unwrap_or(1);
3695                let positions = kept
3696                    .into_iter()
3697                    .map(|k| {
3698                        let (column, row) = ((k - 1) / rows, (k - 1) % rows);
3699                        #[allow(clippy::cast_precision_loss, reason = "array sizes are small")]
3700                        let (x, y) = (column as f64 * dx, row as f64 * dy);
3701                        corner + Vector::new(cos * x - sin * y, sin * x + cos * y, 0.0)
3702                    })
3703                    .collect();
3704                (entity.at(0).int(), scale_at(1), positions)
3705            }
3706            420 => {
3707                let definition = entity.at(0).int();
3708                if let Some(e) = self.file.entity(definition)
3709                    && !matches!(e.kind, 320 | 416)
3710                {
3711                    ogeom_bail!(
3712                        Construction,
3713                        "D{de}: a network instance names a type {} entity, not a network \
3714                         subfigure definition or an external reference to one",
3715                        e.kind
3716                    );
3717                }
3718                // The definition itself, in the file that holds it.
3719                let defined = match self.file.entity(definition) {
3720                    Some(e) if e.kind == 416 => self
3721                        .referenced(definition, e)
3722                        .ok()
3723                        .and_then(|(file, target)| file.entity(target?)),
3724                    found => found,
3725                };
3726                // The type flag: the instance's own, or where it leaves it
3727                // unspecified, its definition's (after its members, at
3728                // 3 + NA).
3729                let flag = match entity.at(7).int() {
3730                    0 => defined
3731                        .filter(|e| e.kind == 320)
3732                        .map_or(0, |e| e.at(3 + e.count(2)).int()),
3733                    flag => flag,
3734                };
3735                if flag == 1 {
3736                    ogeom_bail!(
3737                        Construction,
3738                        "D{de}: a logical network instance is a schematic's, not the \
3739                         model's geometry"
3740                    );
3741                }
3742                // Each axis's scale: x's one where defaulted, y's and z's
3743                // x's. Only a scale alike on every axis places a shape
3744                // rigidly.
3745                let x = scale_at(4);
3746                let or_x = |i: usize| match entity.at(i).real() {
3747                    f if f > 0.0 => f,
3748                    _ => x,
3749                };
3750                let (y, z) = (or_x(5), or_x(6));
3751                if y != x || z != x {
3752                    ogeom_bail!(
3753                        Construction,
3754                        "D{de}: a network instance scales its axes unlike ({x}, {y}, {z}), \
3755                         which distorts what it places"
3756                    );
3757                }
3758                (definition, x, vec![Vector::new(at(1), at(2), at(3))])
3759            }
3760            414 => {
3761                let count = array_count(de, Some(entity.at(1).int()))?;
3762                let kept = kept_positions(entity, 8, count);
3763                let centre = Vector::new(at(2), at(3), at(4));
3764                let radius = at(5);
3765                let (start, delta) = (entity.at(6).real(), entity.at(7).real());
3766                let positions = kept
3767                    .into_iter()
3768                    .map(|k| {
3769                        #[allow(clippy::cast_precision_loss, reason = "array sizes are small")]
3770                        let (sin, cos) = (start + (k - 1) as f64 * delta).sin_cos();
3771                        centre + Vector::new(radius * cos, radius * sin, 0.0)
3772                    })
3773                    .collect();
3774                (entity.at(0).int(), 1.0, positions)
3775            }
3776            kind => ogeom_bail!(
3777                Construction,
3778                "D{de}: type {kind} is not a subfigure instance"
3779            ),
3780        };
3781        let content = self.member(base, built, depth + 1)?;
3782        if positions.len().saturating_mul(content.len()) > MOST_PLACED {
3783            ogeom_bail!(
3784                Construction,
3785                "D{de}: places {} copies of {} shapes, past {MOST_PLACED}",
3786                positions.len(),
3787                content.len()
3788            );
3789        }
3790        let within = self.placement(entity)? * Transform::scaling(Point::ORIGIN, factor, self.tol)?;
3791        let mut copies = Vec::with_capacity(positions.len());
3792        for position in positions {
3793            let motion = Transform::translation(position) * within;
3794            let location = ogeom_topo::Location::of(self.model.add_datum(motion));
3795            copies.push(content.moved(&location));
3796        }
3797        Ok(copies)
3798    }
3799
3800    /// What an entity holds at its own coordinates, as an instance places
3801    /// it: a subfigure definition's (308, 320) or a group's (402) members,
3802    /// an instance's placements within it, a solid, a face, a curve's wire
3803    /// or a point's vertex. Anything else holds nothing and is left for the
3804    /// skipped table. Built once per entity.
3805    fn member(
3806        &mut self,
3807        de: i64,
3808        built: &mut FastMap<i64, Content>,
3809        depth: usize,
3810    ) -> OgeomResult<Content> {
3811        if let Some(found) = built.get(&de) {
3812            return Ok(found.clone());
3813        }
3814        if depth > 32 {
3815            ogeom_bail!(
3816                Construction,
3817                "D{de}: subfigures nest past any sensible depth"
3818            );
3819        }
3820        let Some(entity) = self.file.entity(de) else {
3821            ogeom_bail!(Construction, "IGES pointer D{de} names no entity");
3822        };
3823        let (kind, wire) = (entity.kind, is_wire(entity));
3824        let mut content = Content::default();
3825        match kind {
3826            308 | 320 => content = self.subfigure(de, built, depth)?,
3827            416 => content = self.external(de, depth)?,
3828            402 if matches!(entity.form, 1 | 7 | 14 | 15) => {
3829                content = self.group(de, built, depth)?;
3830            }
3831            408 | 412 | 414 | 420 => {
3832                for copy in self.instance(de, built, depth)? {
3833                    content.extend(copy);
3834                }
3835            }
3836            186 => content.solids.push(self.manifold_solid(de)?),
3837            143 | 144 => content.faces.push(self.face(de)?),
3838            k if UNTRIMMED_KINDS.contains(&k) || is_bounded_plane(entity) => {
3839                content.faces.push(self.untrimmed_face(de)?);
3840            }
3841            k if CSG_KINDS.contains(&k) => content.solids.extend(self.csg_solids(de)?),
3842            _ if wire => content.wires.push(self.wireframe(de)?),
3843            116 => content.points.push(self.point(de)?),
3844            _ => {}
3845        }
3846        built.insert(de, content.clone());
3847        Ok(content)
3848    }
3849
3850    /// A subfigure definition's (308) or network subfigure definition's
3851    /// (320) members at the definition's own coordinates, nested instances
3852    /// placed within it, and the members its matrix [`carried`] moved on
3853    /// by it. Both list their members after a depth, a name and a count; a
3854    /// network definition's connect points, after, are not geometry.
3855    fn subfigure(
3856        &mut self,
3857        de: i64,
3858        built: &mut FastMap<i64, Content>,
3859        depth: usize,
3860    ) -> OgeomResult<Content> {
3861        let entity = self.entity(de)?;
3862        let count = entity.count(2);
3863        let members: Vec<i64> = (0..count).map(|i| entity.at(3 + i).int()).collect();
3864        let placement = self.placement(entity)?;
3865        if placement == Transform::IDENTITY {
3866            return self.members(&members, built, depth);
3867        }
3868        let (moved, left): (Vec<i64>, Vec<i64>) = members
3869            .iter()
3870            .partition(|&&m| self.file.entity(m).is_none_or(carried));
3871        let location = ogeom_topo::Location::of(self.model.add_datum(placement));
3872        let mut content = self.members(&moved, built, depth)?.moved(&location);
3873        content.extend(self.members(&left, built, depth)?);
3874        Ok(content)
3875    }
3876
3877    /// A group's (402, forms 1, 7, 14 and 15) members where it stands as an
3878    /// array's base: each at its own coordinates, as a definition's are.
3879    fn group(
3880        &mut self,
3881        de: i64,
3882        built: &mut FastMap<i64, Content>,
3883        depth: usize,
3884    ) -> OgeomResult<Content> {
3885        let entity = self.entity(de)?;
3886        let count = entity.count(0);
3887        let members: Vec<i64> = (0..count).map(|i| entity.at(1 + i).int().abs()).collect();
3888        self.members(&members, built, depth)
3889    }
3890
3891    /// The file an external reference (416) names, and the definition in
3892    /// it the reference stands for: the entity the file's index (402,
3893    /// form 12) lists under the reference's symbolic name (form 0), or
3894    /// `None` for the whole file (form 1).
3895    fn referenced(&self, de: i64, reference: &Entity) -> OgeomResult<(&'a File, Option<i64>)> {
3896        let symbol = match reference.form {
3897            0 => match reference.at(1).text() {
3898                Some(name) => Some(name),
3899                None => ogeom_bail!(
3900                    Construction,
3901                    "D{de}: an external reference to a definition names no symbol"
3902                ),
3903            },
3904            1 => None,
3905            2 => ogeom_bail!(
3906                Construction,
3907                "D{de}: a logical external reference (416 form 2) relates two entities; \
3908                 it defines nothing to place"
3909            ),
3910            3 | 4 => ogeom_bail!(
3911                Construction,
3912                "D{de}: an external reference (416 form {}) to a definition the receiving \
3913                 system keeps natively names nothing any file holds",
3914                reference.form
3915            ),
3916            form => ogeom_bail!(
3917                Construction,
3918                "D{de}: external reference form {form} is not defined"
3919            ),
3920        };
3921        let id = reference.at(0).text().unwrap_or("");
3922        if file_name(id).is_empty() {
3923            ogeom_bail!(Construction, "D{de}: an external reference names no file");
3924        }
3925        let library = self.library;
3926        let file = match library.file(id) {
3927            Ok(file) => file,
3928            Err(why) => ogeom_bail!(
3929                Construction,
3930                "D{de}: an external reference names the file \"{id}\", which is not \
3931                 to be had: {why}"
3932            ),
3933        };
3934        let Some(symbol) = symbol else {
3935            return Ok((file, None));
3936        };
3937        let target = file
3938            .entities
3939            .values()
3940            .filter(|e| e.kind == 402 && e.form == 12)
3941            .find_map(|index| {
3942                (0..index.count(0)).find_map(|i| {
3943                    (index.at(1 + 2 * i).text() == Some(symbol)).then(|| index.at(2 + 2 * i).int())
3944                })
3945            });
3946        match target {
3947            Some(target) if file.entity(target).is_some() => Ok((file, Some(target.abs()))),
3948            _ => ogeom_bail!(
3949                Construction,
3950                "D{de}: \"{}\" lists no definition named \"{symbol}\" in its index",
3951                file_name(id)
3952            ),
3953        }
3954    }
3955
3956    /// What an external reference (416) stands for, at its file's own
3957    /// coordinates and in millimetres, built into this model: the
3958    /// definition it names, or the whole file as one definition. Its
3959    /// file's warnings join this one's, named by the file.
3960    fn external(&mut self, de: i64, depth: usize) -> OgeomResult<Content> {
3961        let reference = self.entity(de)?;
3962        let (file, target) = self.referenced(de, reference)?;
3963        let name = file_name(reference.at(0).text().unwrap_or("")).to_owned();
3964        let Some(scale_mm) = file.scale_mm() else {
3965            ogeom_bail!(
3966                Construction,
3967                "D{de}: \"{name}\" names units this reader cannot convert to millimetres"
3968            );
3969        };
3970        let model = core::mem::take(&mut self.model);
3971        let mut there = Reader::new(file, model, scale_mm, self.library, self.tol);
3972        there.depth = self.depth;
3973        let mut built = FastMap::default();
3974        let content = match target {
3975            Some(target) => there.member(target, &mut built, depth + 1),
3976            None => there.whole_file(&mut built, depth + 1),
3977        };
3978        self.model = core::mem::take(&mut there.model);
3979        self.report.warnings.extend(
3980            there
3981                .report
3982                .warnings
3983                .into_iter()
3984                .map(|w| format!("{name}: {w}")),
3985        );
3986        self.vertex_misses.0 += there.vertex_misses.0;
3987        self.vertex_misses.1 = self.vertex_misses.1.max(there.vertex_misses.1);
3988        content
3989    }
3990
3991    /// The whole file as one definition, at its own coordinates: the
3992    /// independent shapes [`read_iges`] reads from it, in its order (solids,
3993    /// trimmed surfaces, untrimmed surfaces and bounded planes, constructive
3994    /// solids, subfigure instances, then the curves and points that are
3995    /// geometry, no drawing lists, and nothing before took), its faces left
3996    /// to sew where they are placed.
3997    fn whole_file(
3998        &mut self,
3999        built: &mut FastMap<i64, Content>,
4000        depth: usize,
4001    ) -> OgeomResult<Content> {
4002        let file = self.file;
4003        let on_sheets = on_sheets(file);
4004        let stage = |de: i64, e: &Entity| -> Option<u8> {
4005            if (e.status / 10_000) % 100 != 0 {
4006                return None;
4007            }
4008            let loose = (e.status / 100) % 100 == 0 && !on_sheets.contains(&de);
4009            match e.kind {
4010                186 => Some(0),
4011                143 | 144 => Some(1),
4012                k if UNTRIMMED_KINDS.contains(&k) || is_bounded_plane(e) => Some(2),
4013                k if CSG_KINDS.contains(&k) => Some(3),
4014                k if INSTANCE_KINDS.contains(&k) => Some(4),
4015                _ if is_wire(e) && loose => Some(5),
4016                116 if loose => Some(6),
4017                _ => None,
4018            }
4019        };
4020        let mut content = Content::default();
4021        for step in 0..=6 {
4022            let des: Vec<i64> = file
4023                .entities
4024                .iter()
4025                .filter(|(de, e)| stage(**de, e) == Some(step) && !self.visited.contains_key(de))
4026                .map(|(de, _)| *de)
4027                .collect();
4028            content.extend(self.members(&des, built, depth)?);
4029        }
4030        Ok(content)
4031    }
4032
4033    /// What `members` hold together, at their own coordinates.
4034    fn members(
4035        &mut self,
4036        members: &[i64],
4037        built: &mut FastMap<i64, Content>,
4038        depth: usize,
4039    ) -> OgeomResult<Content> {
4040        let trims: Vec<i64> = members
4041            .iter()
4042            .copied()
4043            .filter(|&m| {
4044                self.file
4045                    .entity(m)
4046                    .is_some_and(|e| matches!(e.kind, 143 | 144))
4047            })
4048            .collect();
4049        let poles = self.poles_of_trims(&trims);
4050        // A nested definition reads its own trims against its own poles,
4051        // and the enclosing ones are back in force once it is read.
4052        let outer_poles = core::mem::replace(&mut self.trim_poles, poles);
4053        let mut content = Content::default();
4054        let mut read_members = || -> OgeomResult<()> {
4055            for &member in members {
4056                let Some(kind) = self.file.entity(member).map(|e| e.kind) else {
4057                    continue;
4058                };
4059                // A solid, a trimmed surface or an instance that fails
4060                // fails the definition; a surface, constructive solid or
4061                // curve beside them that fails is reported and passed over.
4062                match self.member(member, built, depth + 1) {
4063                    Ok(found) => content.extend(found),
4064                    Err(e) if !matches!(kind, 143 | 144 | 186 | 408) => self
4065                        .report
4066                        .warnings
4067                        .push(format!("D{member}: subfigure member not read: {e}")),
4068                    Err(e) => return Err(e),
4069                }
4070            }
4071            Ok(())
4072        };
4073        let read = read_members();
4074        self.trim_poles = outer_poles;
4075        read?;
4076        Ok(content)
4077    }
4078
4079    /// The model-space annotation: every independent drafting entity a
4080    /// drawing does not own, as a callout of the lines the file draws (its
4081    /// leaders, witness lines and symbol geometry, carried by their own
4082    /// placements into the part's coordinates) named by its note's text.
4083    /// A dimension whose text reads as a number is also the semantic
4084    /// dimension of that value, which the callout draws; which geometry
4085    /// it measures the file does not say. A drawing's own annotation lives
4086    /// on the sheet, not on the part, and stays in the skipped table.
4087    fn annotations(
4088        &mut self,
4089    ) -> (
4090        Vec<ogeom_doc::Callout>,
4091        Vec<(ogeom_doc::Callout, ogeom_doc::Dimension)>,
4092    ) {
4093        let on_sheets = on_sheets(self.file);
4094        let drafting: Vec<i64> = self
4095            .file
4096            .entities
4097            .iter()
4098            .filter(|(de, e)| {
4099                matches!(
4100                    e.kind,
4101                    202 | 206 | 208 | 210 | 212 | 214 | 216 | 218 | 220 | 222 | 228
4102                ) && (e.status / 10_000) % 100 == 0
4103                    && !on_sheets.contains(de)
4104            })
4105            .map(|(de, _)| *de)
4106            .collect();
4107        let mut callouts = Vec::new();
4108        let mut dimensions = Vec::new();
4109        for de in drafting {
4110            match self.drafting(de) {
4111                Ok((callout, Some(dimension))) => dimensions.push((callout, dimension)),
4112                Ok((callout, None)) => callouts.push(callout),
4113                Err(e) => self
4114                    .report
4115                    .warnings
4116                    .push(format!("D{de}: annotation not drawn: {e}")),
4117            }
4118        }
4119        (callouts, dimensions)
4120    }
4121
4122    /// One drafting entity as a callout, and the dimension it states where
4123    /// it is one.
4124    fn drafting(
4125        &mut self,
4126        de: i64,
4127    ) -> OgeomResult<(ogeom_doc::Callout, Option<ogeom_doc::Dimension>)> {
4128        let entity = self.entity(de)?;
4129        let pointer = |i: usize| entity.at(i).int();
4130        // Each line drawn, with the subordinate entity that draws it, or 0
4131        // where the entity draws it itself.
4132        let mut polylines: Vec<(i64, Vec<Point>)> = Vec::new();
4133        let mut text = String::new();
4134        let mut measure: Option<(&str, ogeom_doc::MeasureKind)> = None;
4135        let draw =
4136            |reader: &mut Self, lines: &mut Vec<(i64, Vec<Point>)>, de: i64| -> OgeomResult<()> {
4137                if de != 0 {
4138                    lines.push((de, reader.drafted_line(de)?));
4139                }
4140                Ok(())
4141            };
4142        match entity.kind {
4143            212 => text = self.note_text(de)?,
4144            214 => polylines.push((0, self.drafted_line(de)?)),
4145            216 => {
4146                text = self.note_text(pointer(0))?;
4147                for i in 1..=4 {
4148                    draw(self, &mut polylines, pointer(i))?;
4149                }
4150                measure = Some(("linear distance", ogeom_doc::MeasureKind::Length));
4151            }
4152            206 => {
4153                text = self.note_text(pointer(0))?;
4154                for i in 1..=2 {
4155                    draw(self, &mut polylines, pointer(i))?;
4156                }
4157                measure = Some(("diameter", ogeom_doc::MeasureKind::Length));
4158            }
4159            222 => {
4160                text = self.note_text(pointer(0))?;
4161                draw(self, &mut polylines, pointer(1))?;
4162                if entity.params.len() > 4 {
4163                    draw(self, &mut polylines, pointer(4))?;
4164                }
4165                measure = Some(("radius", ogeom_doc::MeasureKind::Length));
4166            }
4167            202 => {
4168                text = self.note_text(pointer(0))?;
4169                for i in [1, 2, 6, 7] {
4170                    draw(self, &mut polylines, pointer(i))?;
4171                }
4172                measure = Some(("angle", ogeom_doc::MeasureKind::Angle));
4173            }
4174            218 | 220 => {
4175                text = self.note_text(pointer(0))?;
4176                draw(self, &mut polylines, pointer(1))?;
4177                measure = Some(("ordinate", ogeom_doc::MeasureKind::Length));
4178                if entity.kind == 220 {
4179                    measure = None;
4180                }
4181            }
4182            208 | 210 => {
4183                // A flag note: its place and angle, then its note and
4184                // leaders; a label: its note, then its leaders.
4185                let at = if entity.kind == 208 { 4 } else { 0 };
4186                text = self.note_text(pointer(at))?;
4187                let n = entity.count(at + 1);
4188                for i in 0..n {
4189                    draw(self, &mut polylines, pointer(at + 2 + i))?;
4190                }
4191            }
4192            228 => {
4193                // Note, the symbol's geometry, then its leaders.
4194                text = self.note_text(pointer(0))?;
4195                let ng = entity.count(1);
4196                for i in 0..ng {
4197                    polylines.push((pointer(2 + i), self.sampled_curve(pointer(2 + i))?));
4198                }
4199                let nl = entity.count(2 + ng);
4200                for i in 0..nl {
4201                    draw(self, &mut polylines, pointer(3 + ng + i))?;
4202                }
4203            }
4204            kind => ogeom_bail!(Construction, "type {kind} is not drafting"),
4205        }
4206        // The entity's matrix carries the lines its subordinate entities
4207        // draw on into the part's coordinates, after their own.
4208        let polylines = polylines
4209            .into_iter()
4210            .map(|(child, line)| {
4211                let motion = if child == 0 {
4212                    Transform::IDENTITY
4213                } else {
4214                    self.parent_motion(entity, child)?
4215                };
4216                Ok(line.into_iter().map(|p| motion.apply(p)).collect())
4217            })
4218            .collect::<OgeomResult<Vec<Vec<Point>>>>()?;
4219        let placement = self.placement(entity)?;
4220        let plane = Frame::new(
4221            placement.apply(Point::ORIGIN),
4222            Direction::new(placement.apply_vector(Vector::Z), self.tol)?,
4223            Direction::new(placement.apply_vector(Vector::X), self.tol)?,
4224            self.tol,
4225        )
4226        .ok();
4227        let callout = ogeom_doc::Callout {
4228            name: text.clone(),
4229            plane,
4230            polylines,
4231            annotates: None,
4232            items: Vec::new(),
4233            text: Some(text.clone()),
4234        };
4235        let dimension = measure.and_then(|(name, kind)| {
4236            let value = first_number(&text)?;
4237            let value = match kind {
4238                ogeom_doc::MeasureKind::Angle => value.to_radians(),
4239                ogeom_doc::MeasureKind::Length => value * self.report.scale_mm,
4240            };
4241            Some(ogeom_doc::Dimension {
4242                name: name.to_owned(),
4243                values: vec![value],
4244                kind,
4245                plus: None,
4246                minus: None,
4247                features: Vec::new(),
4248                location: entity.kind == 216 || entity.kind == 218,
4249            })
4250        });
4251        Ok((callout, dimension))
4252    }
4253
4254    /// A general note's (212) text, its strings one line each.
4255    fn note_text(&mut self, de: i64) -> OgeomResult<String> {
4256        if de == 0 {
4257            return Ok(String::new());
4258        }
4259        let entity = self.entity(de)?;
4260        if entity.kind != 212 {
4261            ogeom_bail!(Construction, "D{de}: type {} is not a note", entity.kind);
4262        }
4263        let count = entity.count(0);
4264        let mut lines = Vec::with_capacity(count);
4265        for i in 0..count {
4266            if let super::parse::Value::Text(t) = entity.at(1 + 12 * i + 11) {
4267                lines.push(t.clone());
4268            }
4269        }
4270        Ok(lines.join("\n"))
4271    }
4272
4273    /// A leader (214) or witness line (106) as the polyline it draws, in
4274    /// the part's coordinates.
4275    fn drafted_line(&mut self, de: i64) -> OgeomResult<Vec<Point>> {
4276        let entity = self.entity(de)?;
4277        let s = self.report.scale_mm;
4278        let mut points = Vec::new();
4279        match entity.kind {
4280            214 => {
4281                let n = entity.count(0);
4282                let z = entity.at(3).real();
4283                points.push(Point::new(
4284                    entity.at(4).real() * s,
4285                    entity.at(5).real() * s,
4286                    z * s,
4287                ));
4288                for i in 0..n {
4289                    points.push(Point::new(
4290                        entity.at(6 + 2 * i).real() * s,
4291                        entity.at(7 + 2 * i).real() * s,
4292                        z * s,
4293                    ));
4294                }
4295            }
4296            106 => {
4297                let n = entity.count(1);
4298                let z = entity.at(2).real();
4299                for i in 0..n {
4300                    points.push(Point::new(
4301                        entity.at(3 + 2 * i).real() * s,
4302                        entity.at(4 + 2 * i).real() * s,
4303                        z * s,
4304                    ));
4305                }
4306            }
4307            _ => return self.sampled_curve(de),
4308        }
4309        let placement = self.placement(entity)?;
4310        Ok(points.into_iter().map(|p| placement.apply(p)).collect())
4311    }
4312
4313    /// Any curve the reader translates, as a polyline for drawing.
4314    fn sampled_curve(&mut self, de: i64) -> OgeomResult<Vec<Point>> {
4315        let (curve, range) = self.curve(de)?;
4316        (0..=32)
4317            .map(|i| {
4318                curve.point_at(
4319                    range.0 + (range.1 - range.0) * f64::from(i) / 32.0,
4320                    self.tol,
4321                )
4322            })
4323            .collect()
4324    }
4325
4326    /// A construction entity's geometry, `None` with a warning where it has
4327    /// no construction form here: a point (116), an axis (124), one curve,
4328    /// or a surface over the window of its own bounds.
4329    fn construction_item(
4330        &mut self,
4331        de: i64,
4332    ) -> OgeomResult<Option<ogeom_doc::ConstructionGeometry>> {
4333        use ogeom_doc::ConstructionGeometry as G;
4334        let entity = self.entity(de)?;
4335        Ok(Some(match entity.kind {
4336            116 => G::Point(self.placement(entity)?.apply(self.point3(entity, 0))),
4337            124 => G::Axis(self.axis(entity)?),
4338            100 => {
4339                let (curve, range) = self.construction_arc(entity)?;
4340                G::Curve { curve, range }
4341            }
4342            144 => {
4343                let surface = self.trimmed_basis(entity, entity.at(0).int())?;
4344                let (u, v) = ogeom_geom::Surface::domain(&surface);
4345                G::Surface { surface, u, v }
4346            }
4347            kind if UNTRIMMED_KINDS.contains(&kind) => {
4348                let surface = self.surface(de)?;
4349                let (u, v) = ogeom_geom::Surface::domain(&surface);
4350                G::Surface { surface, u, v }
4351            }
4352            _ => {
4353                let mut segments = self.curve_segments(de)?;
4354                if segments.len() != 1 {
4355                    self.report.warnings.push(format!(
4356                        "D{de}: a construction curve of {} pieces is no one curve; \
4357                         it is left out",
4358                        segments.len()
4359                    ));
4360                    return Ok(None);
4361                }
4362                let (curve, range) = segments.remove(0);
4363                G::Curve { curve, range }
4364            }
4365        }))
4366    }
4367
4368    /// A circular arc (100) as a construction curve: as [`Self::curve`]
4369    /// reads it, save that its angle runs from the definition space's `x`
4370    /// axis rather than from its start, the parameter a writer's matrix
4371    /// states.
4372    fn construction_arc(&mut self, entity: &Entity) -> OgeomResult<(Curve, (f64, f64))> {
4373        let scale = self.report.scale_mm;
4374        let zt = entity.at(0).real() * scale;
4375        let at = |i: usize| {
4376            Point::new(
4377                entity.at(i).real() * scale,
4378                entity.at(i + 1).real() * scale,
4379                zt,
4380            )
4381        };
4382        let (c, s, e) = (at(1), at(3), at(5));
4383        let angle = |p: Point| (p.y - c.y).atan2(p.x - c.x);
4384        let start = angle(s);
4385        let ang = (angle(e) - start).rem_euclid(core::f64::consts::TAU);
4386        let sweep = if ang <= self.tol.parametric() {
4387            core::f64::consts::TAU
4388        } else {
4389            ang
4390        };
4391        let frame = Frame::new(c, Direction::Z, Direction::X, self.tol)?;
4392        let circle = Circle::new(frame, c.distance(s), self.tol)?;
4393        let curve = Curve::from(CircleCurve::new(circle));
4394        let placement = self.placement(entity)?;
4395        Ok((
4396            curve.transformed(&placement, self.tol)?,
4397            (start, start + sweep),
4398        ))
4399    }
4400
4401    /// The frame a transformation matrix (124) takes the world frame to:
4402    /// its translation the origin and its columns the axes, carried on by
4403    /// the matrix's own placement. Form 1's frame is left-handed.
4404    fn axis(&mut self, matrix: &Entity) -> OgeomResult<Frame> {
4405        let v = |i: usize| matrix.at(i).real();
4406        let s = self.report.scale_mm;
4407        let placement = self.placement(matrix)?;
4408        let column = |k: usize| -> OgeomResult<Direction> {
4409            Direction::new(
4410                placement.apply_vector(Vector::new(v(k), v(4 + k), v(8 + k))),
4411                self.tol,
4412            )
4413        };
4414        let origin = placement.apply(Point::new(v(3) * s, v(7) * s, v(11) * s));
4415        Frame::from_axes(origin, column(0)?, column(1)?, column(2)?, self.tol)
4416    }
4417
4418    /// The name an entity carries in a Name property (406 form 15), past
4419    /// the eight columns of its label: the property pointers follow the
4420    /// parameters its type defines (see [`defined_parameters`]) and its
4421    /// back pointers.
4422    fn long_name(&mut self, de: i64) -> Option<String> {
4423        let entity = self.file.entity(de)?;
4424        let back_at = defined_parameters(entity)?;
4425        let back = usize::try_from(entity.params.get(back_at)?.int()).ok()?;
4426        let count_at = back_at + 1 + back;
4427        let properties = usize::try_from(entity.params.get(count_at)?.int()).ok()?;
4428        for i in 0..properties {
4429            let pointer = entity.params.get(count_at + 1 + i)?.int();
4430            let Some(property) = self.file.entity(pointer) else {
4431                continue;
4432            };
4433            if property.kind == 406 && property.form == 15 {
4434                self.visited.insert(pointer, ());
4435                if let Some(Value::Text(name)) = property.params.get(1) {
4436                    let name = name.trim();
4437                    if !name.is_empty() {
4438                        return Some(name.to_owned());
4439                    }
4440                }
4441            }
4442        }
4443        None
4444    }
4445
4446    fn document(
4447        &mut self,
4448        solids: &[(i64, Shape)],
4449        names: &[Option<String>],
4450        sheets: &[Shape],
4451        construction: Vec<(String, ogeom_doc::ConstructionGeometry)>,
4452    ) -> ogeom_doc::Document {
4453        // A deck states no product its construction geometry belongs to:
4454        // it goes with the first part, or a part of its own holding no
4455        // body where there is none.
4456        let bare = (solids.is_empty() && sheets.is_empty() && !construction.is_empty())
4457            .then(|| ogeom_algo::make_compound(&mut self.model, &[]).map(|built| built.shape));
4458        let colours: Vec<(Shape, ogeom_doc::Colour)> = solids
4459            .iter()
4460            .filter_map(|(de, s)| self.colour_of(*de).map(|c| (s.clone(), c)))
4461            .collect();
4462        let mut document = ogeom_doc::Document::over(std::mem::take(&mut self.model));
4463        for (i, (de, solid)) in solids.iter().enumerate() {
4464            let label = names
4465                .get(i)
4466                .cloned()
4467                .flatten()
4468                .or_else(|| {
4469                    self.file
4470                        .entity(*de)
4471                        .map(|e| e.label.clone())
4472                        .filter(|l| !l.is_empty())
4473                })
4474                .unwrap_or_else(|| format!("solid-{i}"));
4475            document.add_part(label, solid.clone());
4476        }
4477        for (i, sheet) in sheets.iter().enumerate() {
4478            document.add_part(format!("sheet-{i}"), sheet.clone());
4479        }
4480        for (shape, colour) in colours {
4481            document.set_colour(&shape, colour);
4482        }
4483        let owner = match bare {
4484            Some(Ok(nothing)) => Some(document.add_part("construction", nothing)),
4485            Some(Err(e)) => {
4486                self.report
4487                    .warnings
4488                    .push(format!("construction geometry has no part to go in: {e}"));
4489                None
4490            }
4491            None => document.products().next().map(|(id, _)| id),
4492        };
4493        if let Some(owner) = owner {
4494            for (name, geometry) in construction {
4495                if let Err(e) = document.add_construction(owner, name.clone(), geometry) {
4496                    self.report
4497                        .warnings
4498                        .push(format!("construction item '{name}' was refused: {e}"));
4499                }
4500            }
4501        }
4502        document
4503    }
4504
4505    /// The colour a directory entry states: a negated field points at a 314
4506    /// entity's RGB percentages; a small positive names the fixed palette.
4507    fn colour_of(&mut self, de: i64) -> Option<ogeom_doc::Colour> {
4508        let entity = self.file.entity(de)?;
4509        let c = entity.colour;
4510        if c < 0 {
4511            let e = self.file.entity(-c)?;
4512            self.visited.insert(-c, ());
4513            if e.kind != 314 {
4514                return None;
4515            }
4516            return Some(ogeom_doc::Colour::rgb(
4517                (e.at(0).real() / 100.0).clamp(0.0, 1.0),
4518                (e.at(1).real() / 100.0).clamp(0.0, 1.0),
4519                (e.at(2).real() / 100.0).clamp(0.0, 1.0),
4520            ));
4521        }
4522        // 1 black, 2 red, 3 green, 4 blue, 5 yellow, 6 magenta, 7 cyan,
4523        // 8 white: the specification's own palette.
4524        let palette = [
4525            (0.0, 0.0, 0.0),
4526            (1.0, 0.0, 0.0),
4527            (0.0, 1.0, 0.0),
4528            (0.0, 0.0, 1.0),
4529            (1.0, 1.0, 0.0),
4530            (1.0, 0.0, 1.0),
4531            (0.0, 1.0, 1.0),
4532            (1.0, 1.0, 1.0),
4533        ];
4534        let index = usize::try_from(c).ok()?.checked_sub(1)?;
4535        palette
4536            .get(index)
4537            .map(|&(r, g, b)| ogeom_doc::Colour::rgb(r, g, b))
4538    }
4539}
4540
4541/// How many parameters an entity's type defines before its back-pointer
4542/// and property lists, for the types the reader takes a Name property
4543/// (406, form 15) from: a manifold solid, a point, a line, a circular or
4544/// conic arc, a transformation matrix, a B-spline curve or surface, and a
4545/// trimmed surface. `None` for any other type.
4546fn defined_parameters(entity: &Entity) -> Option<usize> {
4547    Some(match entity.kind {
4548        116 => 4,
4549        110 => 6,
4550        100 => 7,
4551        104 => 11,
4552        124 => 12,
4553        186 => 3 + 2 * entity.count(2),
4554        144 => 4 + entity.count(2),
4555        // Six counts and flags, the knots, the weights and control points,
4556        // the parameter range and the plane's normal.
4557        126 => {
4558            let (k, m) = (entity.count(0), entity.count(1));
4559            6 + (k + m + 2) + 4 * (k + 1) + 2 + 3
4560        }
4561        // Nine counts and flags, both knot sequences, the weights and
4562        // control points, and the two parameter ranges.
4563        128 => {
4564            let (k1, k2) = (entity.count(0), entity.count(1));
4565            let (m1, m2) = (entity.count(2), entity.count(3));
4566            9 + (k1 + m1 + 2) + (k2 + m2 + 2) + 4 * (k1 + 1) * (k2 + 1) + 4
4567        }
4568        _ => return None,
4569    })
4570}
4571
4572/// The entities that are construction geometry (entity use flag 06, IGES
4573/// 5.3, section 2.2.4.4.9.3) standing independent and on no drawing, of the
4574/// kinds a document's construction holds: a point (116); a curve that
4575/// stands as wireframe; a transformation matrix (124) no entity is placed
4576/// by, as the axis it takes the world frame to; a surface that bounds
4577/// itself and that no trimmed or bounded surface bounds; and a trimmed
4578/// surface (144) over the whole of one. A trimmed surface with a boundary
4579/// of its own has no construction form, and reads as a face as any other.
4580fn construction_entities(file: &File, on_sheets: &std::collections::BTreeSet<i64>) -> Vec<i64> {
4581    let placements: std::collections::BTreeSet<i64> =
4582        file.entities.values().map(|e| e.transform.abs()).collect();
4583    let bases: std::collections::BTreeSet<i64> = file
4584        .entities
4585        .values()
4586        .filter_map(|e| match e.kind {
4587            143 => Some(e.at(1).int().abs()),
4588            144 => Some(e.at(0).int().abs()),
4589            _ => None,
4590        })
4591        .collect();
4592    let bounds_itself = |de: i64| {
4593        file.entity(de)
4594            .is_some_and(|b| UNTRIMMED_KINDS.contains(&b.kind))
4595    };
4596    file.entities
4597        .iter()
4598        .filter(|(de, e)| {
4599            (e.status / 10_000) % 100 == 0
4600                && (e.status / 100) % 100 == 6
4601                && !on_sheets.contains(de)
4602                && match e.kind {
4603                    116 => true,
4604                    124 => !placements.contains(de),
4605                    144 => e.at(1).int() == 0 && e.count(2) == 0 && bounds_itself(e.at(0).int()),
4606                    kind if UNTRIMMED_KINDS.contains(&kind) => !bases.contains(de),
4607                    _ => is_wire(e),
4608                }
4609        })
4610        .map(|(de, _)| *de)
4611        .collect()
4612}
4613
4614/// The entities a drawing (404) lists as its own annotation: they live on
4615/// the sheet, not on the part.
4616fn on_sheets(file: &File) -> std::collections::BTreeSet<i64> {
4617    let mut on_sheets = std::collections::BTreeSet::new();
4618    for entity in file.entities.values().filter(|e| e.kind == 404) {
4619        let views = entity.count(0);
4620        let at = 1 + 3 * views;
4621        let count = entity.count(at);
4622        for i in 0..count {
4623            on_sheets.insert(entity.at(at + 1 + i).int().abs());
4624        }
4625    }
4626    on_sheets
4627}
4628
4629/// The file's levels and groups as the document's layers: every shape on
4630/// the layer of the level its entity sits on (a negative level names a
4631/// definition-levels property listing several), and every group (402,
4632/// forms 1, 7, 14 and 15) a layer named by the group's label holding the
4633/// shapes its members built.
4634fn layers(
4635    file: &File,
4636    document: &mut ogeom_doc::Document,
4637    built_from: &[(i64, Shape)],
4638    report: &mut IgesReport,
4639) {
4640    let mut by_name: FastMap<String, ogeom_doc::LayerId> = FastMap::default();
4641    let mut layer = |document: &mut ogeom_doc::Document, name: String| {
4642        *by_name
4643            .entry(name.clone())
4644            .or_insert_with(|| document.add_layer(name))
4645    };
4646    for (de, shape) in built_from {
4647        let Some(entity) = file.entity(*de) else {
4648            continue;
4649        };
4650        let levels: Vec<i64> = if entity.level > 0 {
4651            vec![entity.level]
4652        } else if entity.level < 0 {
4653            match file.entity(-entity.level) {
4654                Some(p) if p.kind == 406 && p.form == 1 => {
4655                    let n = p.count(0);
4656                    (0..n).map(|i| p.at(1 + i).int()).collect()
4657                }
4658                _ => {
4659                    report
4660                        .warnings
4661                        .push(format!("D{de}: its level names no levels property"));
4662                    Vec::new()
4663                }
4664            }
4665        } else {
4666            Vec::new()
4667        };
4668        for level in levels {
4669            let id = layer(document, format!("level {level}"));
4670            document.place_on_layer(shape, id);
4671        }
4672    }
4673    for (de, group) in &file.entities {
4674        if group.kind != 402 || !matches!(group.form, 1 | 7 | 14 | 15) {
4675            continue;
4676        }
4677        let n = group.count(0);
4678        let members: Vec<i64> = (0..n).map(|i| group.at(1 + i).int().abs()).collect();
4679        let shapes: Vec<&Shape> = built_from
4680            .iter()
4681            .filter(|(from, _)| members.contains(from))
4682            .map(|(_, s)| s)
4683            .collect();
4684        if shapes.is_empty() {
4685            continue;
4686        }
4687        let name = if group.label.is_empty() {
4688            format!("group D{de}")
4689        } else {
4690            group.label.clone()
4691        };
4692        let id = layer(document, name);
4693        for shape in shapes {
4694            document.place_on_layer(shape, id);
4695        }
4696    }
4697}
4698
4699/// The value a dimension's text states: `R5.5`, `Ø10`, `45°` read as 5.5,
4700/// 10 and 45, and a patterned callout's leading count (`2X Ø5`, `4 x 12.7`)
4701/// passed over for the value after it.
4702fn first_number(text: &str) -> Option<f64> {
4703    let trimmed = text.trim_start();
4704    let count = trimmed
4705        .find(|c: char| !c.is_ascii_digit())
4706        .filter(|&n| n > 0)
4707        .and_then(|n| {
4708            let rest = trimmed[n..].trim_start();
4709            rest.strip_prefix(['X', 'x']).map(|after| after.len())
4710        });
4711    let text = match count {
4712        Some(len) => &trimmed[trimmed.len() - len..],
4713        None => text,
4714    };
4715    let start = text.find(|c: char| c.is_ascii_digit() || c == '.')?;
4716    let tail = &text[start..];
4717    let end = tail
4718        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
4719        .unwrap_or(tail.len());
4720    tail[..end].parse().ok()
4721}
4722
4723/// A trimmed carrier where the range is a strict part of the domain: a
4724/// generatrix used by a sweep is exactly its stated span.
4725/// What deriving an edge's image asks of the reader besides the image: a
4726/// warning to add, or a gap the edge must state, in the order they arise.
4727enum Effect {
4728    Warn(String),
4729    Gap(f64),
4730}
4731
4732/// An edge's image on a surface, `None` where none could be derived, and
4733/// what deriving it asks of the reader.
4734type DerivedImage = (Option<ogeom_geom::PlanarCurve>, Vec<Effect>);
4735
4736/// One edge's image on one surface, exact where the pair has a closed form
4737/// and fitted where it does not: the same policy the STEP reader applies,
4738/// through the shared machinery.
4739fn derive_image(
4740    curve: &Curve,
4741    range: (f64, f64),
4742    surface: &SurfaceGeometry,
4743    tol: Tolerances,
4744) -> DerivedImage {
4745    use ogeom_geom::PlanarCurve;
4746    let widen = |p: PlanarCurve| -> PlanarCurve {
4747        if let PlanarCurve::Line(l) = &p {
4748            use ogeom_geom::Curve2d as _;
4749            let (lo, hi) = (l.domain().0.min(range.0), l.domain().1.max(range.1));
4750            if let Ok(wider) = ogeom_geom::Line2d::over(l.axis(), lo, hi) {
4751                return wider.into();
4752            }
4753        }
4754        p
4755    };
4756    let mut effects = Vec::new();
4757    let found = match ogeom_intersect::exact_pcurve_over(curve, range, surface, tol).map(widen) {
4758        Some(exact) => exact,
4759        None => match crate::pcurves::fit_projected_pcurve(curve, range, surface, tol) {
4760            Ok((fitted, error, met, worst_off, slop)) => {
4761                if let Some(w) = slop {
4762                    effects.push(Effect::Warn(w));
4763                }
4764                if !met {
4765                    effects.push(Effect::Warn(format!(
4766                        "a projected pcurve fit stopped at {error:.2e}; \
4767                         the face's mesh may sit that far off along this edge"
4768                    )));
4769                }
4770                effects.push(Effect::Gap(worst_off));
4771                fitted
4772            }
4773            Err(e) => {
4774                effects.push(Effect::Warn(format!(
4775                    "no pcurve for an edge on this surface ({e}); the \
4776                     face may not triangulate"
4777                )));
4778                return (None, effects);
4779            }
4780        },
4781    };
4782    // The edge provably stands this far from the face it bounds, whether
4783    // the curve lies off the surface or a fit strays between its
4784    // samples. A pcurve that does not evaluate over the edge's range
4785    // states no gap here; the face that uses it fails on it there.
4786    let off =
4787        ogeom_algo::pcurve_fit::lifted_gap((curve, range), (&found, range), surface, false, tol)
4788            .unwrap_or(0.0);
4789    effects.push(Effect::Gap(off));
4790    (Some(found), effects)
4791}
4792
4793fn trimmed_to(curve: Curve, range: (f64, f64), tol: Tolerances) -> OgeomResult<Curve> {
4794    let (lo, hi) = curve.domain();
4795    if (range.0 - lo).abs() < tol.parametric() && (range.1 - hi).abs() < tol.parametric() {
4796        return Ok(curve);
4797    }
4798    Ok(Curve::from(TrimmedCurve::new(
4799        curve, range.0, range.1, tol,
4800    )?))
4801}
4802
4803use crate::inversion::parameter_on;
4804
4805/// A frame with the given axis direction, reference direction chosen stably.
4806fn frame_about(origin: Point, axis: Direction, tol: Tolerances) -> OgeomResult<Frame> {
4807    let seed = if axis.vector().dot(Vector::X).abs() < 0.9 {
4808        Vector::X
4809    } else {
4810        Vector::Y
4811    };
4812    let x = Direction::from_cross(axis.vector(), seed, tol)?;
4813    Frame::new(origin, axis, x, tol)
4814}
4815
4816#[cfg(test)]
4817#[allow(clippy::unwrap_used)]
4818mod tests {
4819    use super::super::parse::Value;
4820    use super::*;
4821
4822    const T: Tolerances = Tolerances::millimetres();
4823
4824    fn entity(kind: i64, form: i64, params: Vec<Value>) -> Entity {
4825        Entity {
4826            kind,
4827            form,
4828            transform: 0,
4829            colour: 0,
4830            level: 0,
4831            status: 0,
4832            label: String::new(),
4833            params,
4834        }
4835    }
4836
4837    fn file(entities: Vec<(i64, Entity)>) -> File {
4838        let mut global = vec![Value::Default; 16];
4839        global[13] = Value::Int(2);
4840        File {
4841            global,
4842            entities: entities.into_iter().collect(),
4843        }
4844    }
4845
4846    fn reader(file: &File) -> Reader<'_> {
4847        Reader::new(file, Model::new(), 1.0, &NO_LIBRARY, T)
4848    }
4849
4850    /// A transform placed by itself, and a composite curve made of itself:
4851    /// both are refused, not followed until the stack runs out.
4852    #[test]
4853    fn entities_that_refer_in_a_circle_are_refused() {
4854        let mut turned = entity(124, 0, vec![Value::Real(1.0); 12]);
4855        turned.transform = 1;
4856        let mut line = entity(110, 0, vec![Value::Real(0.0); 6]);
4857        line.transform = 1;
4858        let f = file(vec![(1, turned), (3, line)]);
4859        let mut r = reader(&f);
4860        assert!(r.curve(3).is_err());
4861
4862        let composite = entity(102, 0, vec![Value::Int(1), Value::Int(1)]);
4863        let f = file(vec![(1, composite)]);
4864        let mut r = reader(&f);
4865        assert!(r.curve(1).is_err());
4866    }
4867
4868    fn reals(values: &[f64]) -> Vec<Value> {
4869        values.iter().map(|v| Value::Real(*v)).collect()
4870    }
4871
4872    fn line(from: [f64; 3], to: [f64; 3]) -> Entity {
4873        entity(
4874            110,
4875            0,
4876            reals(&[from[0], from[1], from[2], to[0], to[1], to[2]]),
4877        )
4878    }
4879
4880    /// A conic arc whose squares differ in sign is a hyperbola, and one
4881    /// missing a square is a parabola; each reads as its own curve, the arc
4882    /// running from the file's start to its terminate point whichever way
4883    /// round the file lists them.
4884    #[test]
4885    fn hyperbola_and_parabola_conic_arcs_read_as_their_curves() {
4886        let (cosh1, sinh1) = (1.0_f64.cosh(), 1.0_f64.sinh());
4887        // x²/4 − y²/9 = 1, the arc from the vertex to parameter one.
4888        let hyperbola = |forward: bool| {
4889            let (s, e) = if forward {
4890                ([2.0, 0.0], [2.0 * cosh1, 3.0 * sinh1])
4891            } else {
4892                ([2.0 * cosh1, 3.0 * sinh1], [2.0, 0.0])
4893            };
4894            entity(
4895                104,
4896                2,
4897                reals(&[
4898                    0.25,
4899                    0.0,
4900                    -1.0 / 9.0,
4901                    0.0,
4902                    0.0,
4903                    -1.0,
4904                    0.0,
4905                    s[0],
4906                    s[1],
4907                    e[0],
4908                    e[1],
4909                ]),
4910            )
4911        };
4912        // x² − 4 y = 0 opening along y, and y² − 4 x = 0 opening along x.
4913        let parabola_y = entity(
4914            104,
4915            3,
4916            reals(&[1.0, 0.0, 0.0, 0.0, -4.0, 0.0, 0.0, 0.0, 0.0, 4.0, 4.0]),
4917        );
4918        let parabola_x = entity(
4919            104,
4920            3,
4921            reals(&[0.0, 0.0, 1.0, -4.0, 0.0, 0.0, 0.0, 4.0, 4.0, 0.0, 0.0]),
4922        );
4923        let deck = file(vec![
4924            (1, hyperbola(true)),
4925            (3, hyperbola(false)),
4926            (5, parabola_y),
4927            (7, parabola_x),
4928        ]);
4929        let mut reader = reader(&deck);
4930        for de in [1, 3] {
4931            let (curve, (t0, t1)) = reader.curve(de).unwrap();
4932            assert!(matches!(curve, Curve::Hyperbola(_)), "D{de} is a hyperbola");
4933            assert!(t1 > t0, "the arc runs forward: {t0} .. {t1}");
4934            let entity = deck.entity(de).unwrap();
4935            let start = Point::new(entity.at(7).real(), entity.at(8).real(), 0.0);
4936            let end = Point::new(entity.at(9).real(), entity.at(10).real(), 0.0);
4937            assert!(curve.point_at(t0, T).unwrap().distance(start) < 1e-9);
4938            assert!(curve.point_at(t1, T).unwrap().distance(end) < 1e-9);
4939            let mid = curve.point_at(f64::midpoint(t0, t1), T).unwrap();
4940            assert!((mid.x * mid.x / 4.0 - mid.y * mid.y / 9.0 - 1.0).abs() < 1e-9);
4941        }
4942        for (de, along_y) in [(5, true), (7, false)] {
4943            let (curve, (t0, t1)) = reader.curve(de).unwrap();
4944            assert!(matches!(curve, Curve::Parabola(_)), "D{de} is a parabola");
4945            assert!(t1 > t0);
4946            let (start, end) = if along_y {
4947                (Point::ORIGIN, Point::new(4.0, 4.0, 0.0))
4948            } else {
4949                (Point::new(4.0, 4.0, 0.0), Point::ORIGIN)
4950            };
4951            assert!(curve.point_at(t0, T).unwrap().distance(start) < 1e-9);
4952            assert!(curve.point_at(t1, T).unwrap().distance(end) < 1e-9);
4953            let mid = curve.point_at(f64::midpoint(t0, t1), T).unwrap();
4954            let residual = if along_y {
4955                mid.x * mid.x - 4.0 * mid.y
4956            } else {
4957                mid.y * mid.y - 4.0 * mid.x
4958            };
4959            assert!(residual.abs() < 1e-9, "on the parabola: {mid:?}");
4960        }
4961    }
4962
4963    /// A ruled surface between two lines is the bilinear patch between
4964    /// them, the second line walked backward where the direction flag says.
4965    #[test]
4966    fn a_ruled_surface_reads_as_the_patch_between_its_curves() {
4967        let deck = file(vec![
4968            (1, line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])),
4969            (3, line([0.0, 5.0, 2.0], [10.0, 5.0, 2.0])),
4970            (
4971                5,
4972                entity(
4973                    118,
4974                    1,
4975                    vec![Value::Int(1), Value::Int(3), Value::Int(0), Value::Int(0)],
4976                ),
4977            ),
4978            (
4979                7,
4980                entity(
4981                    118,
4982                    1,
4983                    vec![Value::Int(1), Value::Int(3), Value::Int(1), Value::Int(0)],
4984                ),
4985            ),
4986        ]);
4987        let mut reader = reader(&deck);
4988        let straight = reader.surface(5).unwrap();
4989        let turned = reader.surface(7).unwrap();
4990        use ogeom_geom::Surface as _;
4991        let middle = straight.point_at(0.5, 0.5, T).unwrap();
4992        assert!(
4993            middle.distance(Point::new(5.0, 2.5, 1.0)) < 1e-9,
4994            "{middle:?}"
4995        );
4996        let far = straight.point_at(0.0, 1.0, T).unwrap();
4997        assert!(far.distance(Point::new(0.0, 5.0, 2.0)) < 1e-9, "{far:?}");
4998        let far = turned.point_at(0.0, 1.0, T).unwrap();
4999        assert!(far.distance(Point::new(10.0, 5.0, 2.0)) < 1e-9, "{far:?}");
5000    }
5001
5002    /// An offset surface displaces its base along the file's direction,
5003    /// and an offset curve along the file's reference crossed with the
5004    /// tangent, whichever way round this vocabulary spells either.
5005    #[test]
5006    fn offset_entities_displace_the_way_the_file_says() {
5007        let deck = file(vec![
5008            (1, entity(108, 0, reals(&[0.0, 0.0, 1.0, 0.0]))),
5009            (
5010                3,
5011                entity(
5012                    140,
5013                    0,
5014                    vec![
5015                        Value::Real(0.0),
5016                        Value::Real(0.0),
5017                        Value::Real(1.0),
5018                        Value::Real(3.0),
5019                        Value::Int(1),
5020                    ],
5021                ),
5022            ),
5023            (
5024                5,
5025                entity(
5026                    140,
5027                    0,
5028                    vec![
5029                        Value::Real(0.0),
5030                        Value::Real(0.0),
5031                        Value::Real(-1.0),
5032                        Value::Real(3.0),
5033                        Value::Int(1),
5034                    ],
5035                ),
5036            ),
5037            (7, line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])),
5038            (
5039                9,
5040                entity(
5041                    130,
5042                    0,
5043                    vec![
5044                        Value::Int(7),
5045                        Value::Int(1),
5046                        Value::Int(0),
5047                        Value::Int(3),
5048                        Value::Int(0),
5049                        Value::Real(2.0),
5050                        Value::Real(0.0),
5051                        Value::Real(2.0),
5052                        Value::Real(0.0),
5053                        Value::Real(0.0),
5054                        Value::Real(0.0),
5055                        Value::Real(1.0),
5056                        Value::Real(0.0),
5057                        Value::Real(10.0),
5058                    ],
5059                ),
5060            ),
5061        ]);
5062        let mut reader = reader(&deck);
5063        use ogeom_geom::Surface as _;
5064        let up = reader.surface(3).unwrap();
5065        assert!((up.point_at(1.0, 2.0, T).unwrap().z - 3.0).abs() < 1e-9);
5066        let down = reader.surface(5).unwrap();
5067        assert!((down.point_at(1.0, 2.0, T).unwrap().z + 3.0).abs() < 1e-9);
5068        let (offset, (t0, t1)) = reader.curve(9).unwrap();
5069        assert!(matches!(offset, Curve::Offset(_)));
5070        assert!((t0, t1) == (0.0, 10.0));
5071        let at = offset.point_at(5.0, T).unwrap();
5072        assert!(at.distance(Point::new(5.0, 2.0, 0.0)) < 1e-9, "{at:?}");
5073    }
5074
5075    /// A conic whose axes turn reads as the ellipse it is: `x'²/16 + y'²/4
5076    /// = 1` turned thirty degrees, written with its cross term.
5077    #[test]
5078    fn a_rotated_conic_reads_as_its_turned_curve() {
5079        let theta = 30.0_f64.to_radians();
5080        let (sin, cos) = theta.sin_cos();
5081        let (a2, b2) = (16.0, 4.0);
5082        let coefficients = [
5083            cos * cos / a2 + sin * sin / b2,
5084            2.0 * sin * cos * (1.0 / a2 - 1.0 / b2),
5085            sin * sin / a2 + cos * cos / b2,
5086            0.0,
5087            0.0,
5088            -1.0,
5089        ];
5090        let start = [4.0 * cos, 4.0 * sin];
5091        let end = [-2.0 * sin, 2.0 * cos];
5092        let mut values = coefficients.to_vec();
5093        values.extend([0.0, start[0], start[1], end[0], end[1]]);
5094        let deck = file(vec![(1, entity(104, 1, reals(&values)))]);
5095        let mut reader = reader(&deck);
5096        let (curve, (t0, t1)) = reader.curve(1).unwrap();
5097        assert!(matches!(curve, Curve::Ellipse(_)), "a turned ellipse");
5098        assert!(
5099            curve
5100                .point_at(t0, T)
5101                .unwrap()
5102                .distance(Point::new(start[0], start[1], 0.0))
5103                < 1e-9
5104        );
5105        assert!(
5106            curve
5107                .point_at(t1, T)
5108                .unwrap()
5109                .distance(Point::new(end[0], end[1], 0.0))
5110                < 1e-9
5111        );
5112        for k in 0..=8 {
5113            let p = curve
5114                .point_at(t0 + (t1 - t0) * f64::from(k) / 8.0, T)
5115                .unwrap();
5116            let [a, b, c, _, _, f] = coefficients;
5117            let residual = a * p.x * p.x + b * p.x * p.y + c * p.y * p.y + f;
5118            assert!(residual.abs() < 1e-9, "on the conic: {p:?}");
5119        }
5120    }
5121
5122    /// An offset growing linearly with arc length (type 2) from one at the
5123    /// base line's start to three at its end: the displaced curve rises
5124    /// along the reference crossed with the tangent.
5125    #[test]
5126    fn a_linearly_varying_offset_curve_reads_as_its_fitted_displacement() {
5127        let deck = file(vec![
5128            (1, line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])),
5129            (
5130                3,
5131                entity(
5132                    130,
5133                    0,
5134                    vec![
5135                        Value::Int(1),
5136                        Value::Int(2),
5137                        Value::Int(0),
5138                        Value::Int(0),
5139                        Value::Int(0),
5140                        Value::Real(1.0),
5141                        Value::Real(0.0),
5142                        Value::Real(3.0),
5143                        Value::Real(10.0),
5144                        Value::Real(0.0),
5145                        Value::Real(0.0),
5146                        Value::Real(1.0),
5147                        Value::Real(0.0),
5148                        Value::Real(0.0),
5149                    ],
5150                ),
5151            ),
5152        ]);
5153        let mut reader = reader(&deck);
5154        let (curve, (t0, t1)) = reader.curve(3).unwrap();
5155        for k in 0..=10 {
5156            let t = t0 + (t1 - t0) * f64::from(k) / 10.0;
5157            let p = curve.point_at(t, T).unwrap();
5158            let want = Point::new(p.x, 1.0 + 2.0 * p.x / 10.0, 0.0);
5159            assert!(p.distance(want) < 1e-5, "{p:?} against {want:?}");
5160        }
5161        assert!(
5162            curve
5163                .point_at(t0, T)
5164                .unwrap()
5165                .distance(Point::new(0.0, 1.0, 0.0))
5166                < 1e-5
5167        );
5168        assert!(
5169            curve
5170                .point_at(t1, T)
5171                .unwrap()
5172                .distance(Point::new(10.0, 3.0, 0.0))
5173                < 1e-5
5174        );
5175    }
5176
5177    /// An offset varying with arc length along a base of more spans than
5178    /// any fixed sampling follows reads as the displaced curve everywhere
5179    /// along it, not only at the samples.
5180    #[test]
5181    fn a_varying_offset_of_a_many_span_base_holds_between_the_samples() {
5182        use ogeom_geom::Curve3d as _;
5183        const SPANS: usize = 400;
5184        let n = SPANS + 3;
5185        let mut base = vec![
5186            Value::Int(i64::try_from(n - 1).unwrap()),
5187            Value::Int(3),
5188            Value::Int(1),
5189            Value::Int(0),
5190            Value::Int(1),
5191            Value::Int(0),
5192        ];
5193        let mut knots = vec![0.0; 4];
5194        #[allow(clippy::cast_precision_loss)]
5195        knots.extend((1..SPANS).map(|i| i as f64 / SPANS as f64));
5196        knots.extend([1.0; 4]);
5197        base.extend(reals(&knots));
5198        base.extend(reals(&vec![1.0; n]));
5199        for i in 0..n {
5200            #[allow(clippy::cast_precision_loss)]
5201            let x = 10.0 * i as f64 / (n - 1) as f64;
5202            base.extend(reals(&[
5203                x,
5204                if i.is_multiple_of(2) { 5e-4 } else { -5e-4 },
5205                0.0,
5206            ]));
5207        }
5208        base.extend(reals(&[0.0, 1.0, 0.0, 0.0, 1.0]));
5209        let deck = file(vec![(1, entity(126, 0, base)), (3, entity(130, 0, vec![]))]);
5210        let mut reader = reader(&deck);
5211        let (basis, range) = reader.curve(1).unwrap();
5212        let length = ogeom_algo::curve_length(&basis, range, T).unwrap();
5213        let offset = entity(
5214            130,
5215            0,
5216            vec![
5217                Value::Int(1),
5218                Value::Int(2),
5219                Value::Int(0),
5220                Value::Int(0),
5221                Value::Int(0),
5222                Value::Real(0.05),
5223                Value::Real(0.0),
5224                Value::Real(0.1),
5225                Value::Real(length),
5226                Value::Real(0.0),
5227                Value::Real(0.0),
5228                Value::Real(1.0),
5229                Value::Real(0.0),
5230                Value::Real(0.0),
5231            ],
5232        );
5233        let deck = file(vec![(1, deck.entities[&1].clone()), (3, offset)]);
5234        let mut reader = super::tests::reader(&deck);
5235        let (curve, (t0, t1)) = reader.curve(3).unwrap();
5236        let mut worst = 0.0_f64;
5237        let mut along = 0.0;
5238        let mut last = t0;
5239        for k in 0..=4000 {
5240            let t = t0 + (t1 - t0) * f64::from(k) / 4000.0;
5241            along += ogeom_algo::curve_length(&basis, (last, t), T).unwrap();
5242            last = t;
5243            let d = 0.05 + 0.05 * along / length;
5244            let side = Vector::Z.cross(basis.d1_at(t, T).unwrap());
5245            let want = basis.point_at(t, T).unwrap() + side * (d / side.magnitude());
5246            worst = worst.max(curve.point_at(t, T).unwrap().distance(want));
5247        }
5248        assert!(
5249            worst < 1e-4,
5250            "the offset strays {worst} between its samples: {:?}",
5251            reader.report.warnings
5252        );
5253    }
5254
5255    /// The parametric spline surface `(s, t, s·t)`: one patch over
5256    /// `[0, 2] × [0, 3]`, and the same surface as two patches split at
5257    /// `s = 1` (the second re-expanded about its own break point). Both
5258    /// read as the exact bicubic B-spline through the same points.
5259    #[test]
5260    fn a_parametric_spline_surface_reads_as_its_exact_bspline() {
5261        let patch = |x: &[(usize, f64)], y: &[(usize, f64)], z: &[(usize, f64)]| -> Vec<f64> {
5262            let mut out = vec![0.0; 48];
5263            for (axis, terms) in [x, y, z].iter().enumerate() {
5264                for &(k, v) in *terms {
5265                    out[16 * axis + k] = v;
5266                }
5267            }
5268            out
5269        };
5270        let header = |m: i64, n: i64, tu: &[f64], tv: &[f64]| -> Vec<Value> {
5271            let mut out = vec![Value::Int(3), Value::Int(0), Value::Int(m), Value::Int(n)];
5272            out.extend(reals(tu));
5273            out.extend(reals(tv));
5274            out
5275        };
5276        // x = s, y = t, z = s·t: index 1 is s, 4 is t, 5 is s·t.
5277        let whole = {
5278            let mut v = header(1, 1, &[0.0, 2.0], &[0.0, 3.0]);
5279            v.extend(reals(&patch(&[(1, 1.0)], &[(4, 1.0)], &[(5, 1.0)])));
5280            v.extend(reals(&[0.0; 48]));
5281            v.extend(reals(&[0.0; 96]));
5282            v
5283        };
5284        // Split at s = 1: the second patch about s = 1 is x = 1 + s',
5285        // z = t + s'·t.
5286        let split = {
5287            let mut v = header(2, 1, &[0.0, 1.0, 2.0], &[0.0, 3.0]);
5288            v.extend(reals(&patch(&[(1, 1.0)], &[(4, 1.0)], &[(5, 1.0)])));
5289            v.extend(reals(&[0.0; 48]));
5290            v.extend(reals(&patch(
5291                &[(0, 1.0), (1, 1.0)],
5292                &[(4, 1.0)],
5293                &[(4, 1.0), (5, 1.0)],
5294            )));
5295            v.extend(reals(&[0.0; 48]));
5296            v.extend(reals(&[0.0; 96]));
5297            v
5298        };
5299        let deck = file(vec![(1, entity(114, 0, whole)), (3, entity(114, 0, split))]);
5300        let mut reader = reader(&deck);
5301        for de in [1, 3] {
5302            let surface = reader.surface(de).unwrap();
5303            for (u, v) in [(0.0, 0.0), (0.5, 1.0), (1.0, 2.5), (1.5, 0.7), (2.0, 3.0)] {
5304                let p = ogeom_geom::Surface::point_at(&surface, u, v, T).unwrap();
5305                let want = Point::new(u, v, u * v);
5306                assert!(p.distance(want) < 1e-12, "D{de} at ({u}, {v}): {p:?}");
5307            }
5308        }
5309    }
5310
5311    /// A curve-on-surface is creation, surface, parameter-space curve,
5312    /// model-space curve: the model curve is read where the file gives it,
5313    /// and a trim given only in the surface's parameters is lifted through
5314    /// a B-spline surface.
5315    #[test]
5316    fn a_parameter_space_trim_lifts_through_its_bspline_surface() {
5317        // The bilinear patch (10u, 10v, 0) over the unit square.
5318        let surface = entity(
5319            128,
5320            0,
5321            vec![
5322                Value::Int(1),
5323                Value::Int(1),
5324                Value::Int(1),
5325                Value::Int(1),
5326                Value::Int(0),
5327                Value::Int(0),
5328                Value::Int(1),
5329                Value::Int(0),
5330                Value::Int(0),
5331                Value::Real(0.0),
5332                Value::Real(0.0),
5333                Value::Real(1.0),
5334                Value::Real(1.0),
5335                Value::Real(0.0),
5336                Value::Real(0.0),
5337                Value::Real(1.0),
5338                Value::Real(1.0),
5339                Value::Real(1.0),
5340                Value::Real(1.0),
5341                Value::Real(1.0),
5342                Value::Real(1.0),
5343                Value::Real(0.0),
5344                Value::Real(0.0),
5345                Value::Real(0.0),
5346                Value::Real(10.0),
5347                Value::Real(0.0),
5348                Value::Real(0.0),
5349                Value::Real(0.0),
5350                Value::Real(10.0),
5351                Value::Real(0.0),
5352                Value::Real(10.0),
5353                Value::Real(10.0),
5354                Value::Real(0.0),
5355                Value::Real(0.0),
5356                Value::Real(1.0),
5357                Value::Real(0.0),
5358                Value::Real(1.0),
5359            ],
5360        );
5361        let on_surface = |model_curve: i64| {
5362            entity(
5363                142,
5364                0,
5365                vec![
5366                    Value::Int(0),
5367                    Value::Int(1),
5368                    Value::Int(3),
5369                    Value::Int(model_curve),
5370                    Value::Int(0),
5371                ],
5372            )
5373        };
5374        let deck = file(vec![
5375            (1, surface),
5376            (3, line([0.2, 0.2, 0.0], [0.8, 0.8, 0.0])),
5377            (5, on_surface(0)),
5378            (7, line([2.0, 2.0, 0.0], [8.0, 8.0, 0.0])),
5379            (9, on_surface(7)),
5380        ]);
5381        let mut reader = reader(&deck);
5382        for de in [5, 9] {
5383            let segments = reader.boundary_segments(de).unwrap();
5384            let [(curve, (t0, t1))] = segments.as_slice() else {
5385                panic!("one segment");
5386            };
5387            assert!(
5388                curve
5389                    .point_at(*t0, T)
5390                    .unwrap()
5391                    .distance(Point::new(2.0, 2.0, 0.0))
5392                    < 1e-6
5393            );
5394            assert!(
5395                curve
5396                    .point_at(*t1, T)
5397                    .unwrap()
5398                    .distance(Point::new(8.0, 8.0, 0.0))
5399                    < 1e-6
5400            );
5401            let mid = curve.point_at(f64::midpoint(*t0, *t1), T).unwrap();
5402            assert!(
5403                (mid.x - mid.y).abs() < 1e-6 && mid.z.abs() < 1e-9,
5404                "D{de} {mid:?}"
5405            );
5406        }
5407    }
5408
5409    /// A trim given only in a patch's parameters as a polyline of more
5410    /// corners than any fixed sampling holds lifts with every corner where
5411    /// the file put it.
5412    #[test]
5413    fn a_many_cornered_parameter_space_trim_keeps_its_corners() {
5414        // The bilinear patch (10u, 10v, 0) over the unit square.
5415        let mut patch = vec![
5416            Value::Int(1),
5417            Value::Int(1),
5418            Value::Int(1),
5419            Value::Int(1),
5420            Value::Int(0),
5421            Value::Int(0),
5422            Value::Int(1),
5423            Value::Int(0),
5424            Value::Int(0),
5425        ];
5426        patch.extend(reals(&[0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0]));
5427        patch.extend(reals(&[1.0; 4]));
5428        patch.extend(reals(&[
5429            0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0,
5430        ]));
5431        patch.extend(reals(&[0.0, 1.0, 0.0, 1.0]));
5432        // A zigzag of 301 corners in the patch's parameters.
5433        const CORNERS: usize = 301;
5434        let corner = |i: usize| -> [f64; 2] {
5435            #[allow(clippy::cast_precision_loss)]
5436            let f = i as f64 / (CORNERS - 1) as f64;
5437            [0.1 + 0.8 * f, if i.is_multiple_of(2) { 0.49 } else { 0.51 }]
5438        };
5439        let mut zigzag = vec![
5440            Value::Int(i64::try_from(CORNERS - 1).unwrap()),
5441            Value::Int(1),
5442            Value::Int(1),
5443            Value::Int(0),
5444            Value::Int(1),
5445            Value::Int(0),
5446        ];
5447        let mut knots = vec![0.0];
5448        #[allow(clippy::cast_precision_loss)]
5449        knots.extend((0..CORNERS).map(|i| i as f64 / (CORNERS - 1) as f64));
5450        knots.push(1.0);
5451        zigzag.extend(reals(&knots));
5452        zigzag.extend(reals(&[1.0; CORNERS]));
5453        for i in 0..CORNERS {
5454            let [u, v] = corner(i);
5455            zigzag.extend(reals(&[u, v, 0.0]));
5456        }
5457        zigzag.extend(reals(&[0.0, 1.0, 0.0, 0.0, 1.0]));
5458        let deck = file(vec![
5459            (1, entity(128, 0, patch)),
5460            (3, entity(126, 0, zigzag)),
5461            (
5462                5,
5463                entity(
5464                    142,
5465                    0,
5466                    vec![
5467                        Value::Int(0),
5468                        Value::Int(1),
5469                        Value::Int(3),
5470                        Value::Int(0),
5471                        Value::Int(0),
5472                    ],
5473                ),
5474            ),
5475        ]);
5476        let mut reader = reader(&deck);
5477        let segments = reader.boundary_segments(5).unwrap();
5478        let [(curve, (t0, t1))] = segments.as_slice() else {
5479            panic!("one segment");
5480        };
5481        // Each corner, lifted, against the nearest point of the read trim.
5482        let fine: Vec<Point> = (0..=60_000)
5483            .map(|k| {
5484                let t = t0 + (t1 - t0) * f64::from(k) / 60_000.0;
5485                curve.point_at(t, T).unwrap()
5486            })
5487            .collect();
5488        let mut worst = 0.0_f64;
5489        for i in (0..CORNERS).step_by(7) {
5490            let [u, v] = corner(i);
5491            let want = Point::new(10.0 * u, 10.0 * v, 0.0);
5492            let near = fine
5493                .iter()
5494                .map(|p| p.distance(want))
5495                .fold(f64::INFINITY, f64::min);
5496            worst = worst.max(near);
5497        }
5498        assert!(
5499            worst < 1e-6,
5500            "a corner lost by {worst}: {:?}",
5501            reader.report.warnings
5502        );
5503    }
5504
5505    /// A trim given in an analytic surface's own parameters lifts through
5506    /// the format's parameterization: a cylinder's angle in degrees and
5507    /// height, a sphere's two angles, a torus's pair turned; each angle
5508    /// measured from the surface's reference direction.
5509    #[test]
5510    fn a_parameter_space_trim_lifts_through_an_analytic_surface() {
5511        let point = |p: [f64; 3]| entity(116, 0, reals(&p));
5512        let direction = |d: [f64; 3]| entity(123, 0, reals(&d));
5513        let on_surface = |surface: i64| {
5514            entity(
5515                142,
5516                0,
5517                vec![
5518                    Value::Int(0),
5519                    Value::Int(surface),
5520                    Value::Int(3),
5521                    Value::Int(0),
5522                    Value::Int(0),
5523                ],
5524            )
5525        };
5526        // Location, axis +z, reference +y (so angle zero points along +y).
5527        type Case = (Entity, [f64; 2], [f64; 2], Point, Point);
5528        let cases: Vec<Case> = vec![
5529            (
5530                // Cylinder radius 2: angle 0 -> 90 degrees, height 0 -> 5.
5531                entity(
5532                    192,
5533                    1,
5534                    vec![
5535                        Value::Int(11),
5536                        Value::Int(13),
5537                        Value::Real(2.0),
5538                        Value::Int(15),
5539                    ],
5540                ),
5541                [0.0, 0.0],
5542                [90.0, 5.0],
5543                Point::new(0.0, 2.0, 0.0),
5544                Point::new(-2.0, 0.0, 5.0),
5545            ),
5546            (
5547                // Sphere radius 3: from (0, 0) to (90 degrees, 90 degrees), the pole.
5548                entity(
5549                    196,
5550                    1,
5551                    vec![
5552                        Value::Int(11),
5553                        Value::Real(3.0),
5554                        Value::Int(13),
5555                        Value::Int(15),
5556                    ],
5557                ),
5558                [0.0, 0.0],
5559                [90.0, 45.0],
5560                Point::new(0.0, 3.0, 0.0),
5561                Point::new(
5562                    -3.0 * core::f64::consts::FRAC_1_SQRT_2,
5563                    0.0,
5564                    3.0 * core::f64::consts::FRAC_1_SQRT_2,
5565                ),
5566            ),
5567            (
5568                // Torus 5 by 1: IGES (u round the tube, v round the axis
5569                // measured back from a full turn). (0, 360) is the outer
5570                // equator at angle zero; (90, 270) a quarter round each.
5571                entity(
5572                    198,
5573                    1,
5574                    vec![
5575                        Value::Int(11),
5576                        Value::Int(13),
5577                        Value::Real(5.0),
5578                        Value::Real(1.0),
5579                        Value::Int(15),
5580                    ],
5581                ),
5582                [0.0, 360.0],
5583                [90.0, 270.0],
5584                Point::new(0.0, 6.0, 0.0),
5585                Point::new(-5.0, 0.0, 1.0),
5586            ),
5587        ];
5588        for (surface, from, to, at_from, at_to) in cases {
5589            let deck = file(vec![
5590                (1, surface),
5591                (3, line([from[0], from[1], 0.0], [to[0], to[1], 0.0])),
5592                (5, on_surface(1)),
5593                (11, point([0.0, 0.0, 0.0])),
5594                (13, direction([0.0, 0.0, 1.0])),
5595                (15, direction([0.0, 1.0, 0.0])),
5596            ]);
5597            let mut reader = reader(&deck);
5598            let segments = reader.boundary_segments(5).unwrap();
5599            let [(curve, (t0, t1))] = segments.as_slice() else {
5600                panic!("one segment");
5601            };
5602            let (a, b) = (
5603                curve.point_at(*t0, T).unwrap(),
5604                curve.point_at(*t1, T).unwrap(),
5605            );
5606            assert!(a.distance(at_from) < 1e-6, "{a:?} against {at_from:?}");
5607            assert!(b.distance(at_to) < 1e-6, "{b:?} against {at_to:?}");
5608        }
5609    }
5610
5611    /// Copious data drawn as a path is the polyline through its points,
5612    /// pairs at a common height or triples, a repeated point adding no
5613    /// segment and form 63 closing on its first point; a set of points
5614    /// (forms 1 to 3) is no curve, and is refused by name.
5615    #[test]
5616    fn copious_data_paths_read_as_their_polylines() {
5617        let path = |flag: i64, count: i64, values: &[f64]| -> Vec<Value> {
5618            let mut out = vec![Value::Int(flag), Value::Int(count)];
5619            out.extend(reals(values));
5620            out
5621        };
5622        let pairs = [2.0, 0.0, 0.0, 3.0, 0.0, 3.0, 4.0];
5623        let cases = [
5624            (11, path(1, 3, &pairs), 7.0, 3),
5625            (63, path(1, 3, &pairs), 12.0, 4),
5626            (
5627                12,
5628                path(2, 3, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5.0]),
5629                5.0,
5630                2,
5631            ),
5632        ];
5633        for (form, params, length, points) in cases {
5634            let deck = file(vec![(1, entity(106, form, params))]);
5635            let mut r = reader(&deck);
5636            let (curve, range) = r.curve(1).unwrap();
5637            let Curve::BSpline(polyline) = &curve else {
5638                panic!("form {form}: a polyline");
5639            };
5640            assert_eq!(polyline.control_points().len(), points, "form {form}");
5641            let walked = ogeom_algo::curve_length(&curve, range, T).unwrap();
5642            assert!((walked - length).abs() < 1e-12, "form {form}: {walked}");
5643        }
5644        let deck = file(vec![(1, entity(106, 2, path(2, 1, &[0.0, 0.0, 0.0])))]);
5645        let err = reader(&deck).curve(1).unwrap_err();
5646        assert!(err.to_string().contains("a set of points"), "{err}");
5647    }
5648
5649    /// A trim on a surface of revolution of an axis-aligned ellipse is in
5650    /// the ellipse's own angle `t` of `(cx + a cos t, cy + b sin t)` and the
5651    /// angle turned, whichever of `a` and `b` is the longer and wherever
5652    /// the arc starts; a hyperbola's parameter is not carried, and its trim
5653    /// is refused by name.
5654    #[test]
5655    fn a_parameter_space_trim_lifts_through_a_revolved_ellipse() {
5656        let big = 10.0;
5657        // `sign` one for the ellipse `(x / rx)² + ((y - R) / ry)² = 1`, minus
5658        // one for the hyperbola `(x / rx)² - ((y - R) / ry)² = 1`.
5659        let lifted = |rx: f64, ry: f64, t_s: f64, t_e: f64, sign: f64| {
5660            let at = |t: f64| {
5661                if sign > 0.0 {
5662                    (rx * t.cos(), big + ry * t.sin())
5663                } else {
5664                    (rx * t.cosh(), big + ry * t.sinh())
5665                }
5666            };
5667            let (s, e) = (at(t_s), at(t_e));
5668            let ellipse = entity(
5669                104,
5670                if sign > 0.0 { 1 } else { 2 },
5671                reals(&[
5672                    1.0 / (rx * rx),
5673                    0.0,
5674                    sign / (ry * ry),
5675                    0.0,
5676                    -2.0 * big * sign / (ry * ry),
5677                    big * big * sign / (ry * ry) - 1.0,
5678                    0.0,
5679                    s.0,
5680                    s.1,
5681                    e.0,
5682                    e.1,
5683                ]),
5684            );
5685            let revolved = entity(
5686                120,
5687                0,
5688                vec![
5689                    Value::Int(9),
5690                    Value::Int(7),
5691                    Value::Real(0.0),
5692                    Value::Real(core::f64::consts::FRAC_PI_2),
5693                ],
5694            );
5695            let on_surface = entity(
5696                142,
5697                0,
5698                vec![
5699                    Value::Int(0),
5700                    Value::Int(1),
5701                    Value::Int(3),
5702                    Value::Int(0),
5703                    Value::Int(0),
5704                ],
5705            );
5706            let (from, to) = ([t_s + 0.2, 0.1], [t_e - 0.3, 1.2]);
5707            let deck = file(vec![
5708                (1, revolved),
5709                (3, line([from[0], from[1], 0.0], [to[0], to[1], 0.0])),
5710                (5, on_surface),
5711                (7, ellipse),
5712                (9, line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])),
5713            ]);
5714            let mut reader = reader(&deck);
5715            let segments = reader.boundary_segments(5)?;
5716            let [(curve, (t0, t1))] = segments.as_slice() else {
5717                panic!("one segment");
5718            };
5719            let ends = (curve.point_at(*t0, T)?, curve.point_at(*t1, T)?);
5720            // (x, y) turned about x through phi.
5721            let expect = |t: f64, phi: f64| {
5722                let (x, y) = at(t);
5723                Point::new(x, y * phi.cos(), y * phi.sin())
5724            };
5725            Ok::<_, ogeom_core::OgeomError>((
5726                ends,
5727                (expect(from[0], from[1]), expect(to[0], to[1])),
5728            ))
5729        };
5730        for (rx, ry, t_s, t_e) in [(3.0, 2.0, 0.5, 2.0), (2.0, 3.0, 4.0, 5.5)] {
5731            let ((a, b), (want_a, want_b)) = lifted(rx, ry, t_s, t_e, 1.0).unwrap();
5732            assert!(
5733                a.distance(want_a) < 1e-9,
5734                "{rx} x {ry}: {a:?} against {want_a:?}"
5735            );
5736            assert!(
5737                b.distance(want_b) < 1e-9,
5738                "{rx} x {ry}: {b:?} against {want_b:?}"
5739            );
5740        }
5741    }
5742
5743    /// A tabulated cylinder's trim is in fractions of its directrix and
5744    /// generator; a surface of revolution's in its generatrix's own
5745    /// parameter (a line's over [0, 1]) and the angle turned.
5746    #[test]
5747    fn a_parameter_space_trim_lifts_through_a_swept_surface() {
5748        let on_surface = |surface: i64| {
5749            entity(
5750                142,
5751                0,
5752                vec![
5753                    Value::Int(0),
5754                    Value::Int(surface),
5755                    Value::Int(3),
5756                    Value::Int(0),
5757                    Value::Int(0),
5758                ],
5759            )
5760        };
5761        // 122: the directrix (0,0,0)-(10,0,0), generator to (0,0,4): the
5762        // parameters (0.5, 0) and (0.5, 1) are (5,0,0) and (5,0,4).
5763        let tabulated = entity(
5764            122,
5765            0,
5766            vec![
5767                Value::Int(7),
5768                Value::Real(0.0),
5769                Value::Real(0.0),
5770                Value::Real(4.0),
5771            ],
5772        );
5773        // 120: the axis along z through the origin, the generatrix the
5774        // line (2,0,0)-(2,0,6) turned from 0 to a quarter turn: (0, 0) is
5775        // (2,0,0) and (1, pi/2) is (0,2,6).
5776        let revolved = entity(
5777            120,
5778            0,
5779            vec![
5780                Value::Int(9),
5781                Value::Int(7),
5782                Value::Real(0.0),
5783                Value::Real(core::f64::consts::FRAC_PI_2),
5784            ],
5785        );
5786        let cases = [
5787            (
5788                tabulated,
5789                line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0]),
5790                [0.5, 0.0],
5791                [0.5, 1.0],
5792                Point::new(5.0, 0.0, 0.0),
5793                Point::new(5.0, 0.0, 4.0),
5794            ),
5795            (
5796                revolved,
5797                line([2.0, 0.0, 0.0], [2.0, 0.0, 6.0]),
5798                [0.0, 0.0],
5799                [1.0, core::f64::consts::FRAC_PI_2],
5800                Point::new(2.0, 0.0, 0.0),
5801                Point::new(0.0, 2.0, 6.0),
5802            ),
5803        ];
5804        for (surface, curve, from, to, at_from, at_to) in cases {
5805            let deck = file(vec![
5806                (1, surface),
5807                (3, line([from[0], from[1], 0.0], [to[0], to[1], 0.0])),
5808                (5, on_surface(1)),
5809                (7, curve),
5810                (9, line([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
5811            ]);
5812            let mut reader = reader(&deck);
5813            let segments = reader.boundary_segments(5).unwrap();
5814            let [(lifted, (t0, t1))] = segments.as_slice() else {
5815                panic!("one segment");
5816            };
5817            let (a, b) = (
5818                lifted.point_at(*t0, T).unwrap(),
5819                lifted.point_at(*t1, T).unwrap(),
5820            );
5821            assert!(a.distance(at_from) < 1e-6, "{a:?} against {at_from:?}");
5822            assert!(b.distance(at_to) < 1e-6, "{b:?} against {at_to:?}");
5823        }
5824    }
5825
5826    /// A linear dimension in model space draws its leaders and witness
5827    /// lines, is named by its note's text, and states the value that text
5828    /// reads; a note a drawing owns stays on the sheet.
5829    #[test]
5830    fn a_model_space_dimension_reads_as_its_callout_and_value() {
5831        let note = |text: &str| {
5832            let mut e = entity(
5833                212,
5834                0,
5835                vec![
5836                    Value::Int(1),
5837                    Value::Int(5),
5838                    Value::Real(10.0),
5839                    Value::Real(3.0),
5840                    Value::Int(1),
5841                    Value::Real(0.0),
5842                    Value::Real(0.0),
5843                    Value::Int(0),
5844                    Value::Int(0),
5845                    Value::Real(10.0),
5846                    Value::Real(12.0),
5847                    Value::Real(0.0),
5848                    Value::Text(text.to_owned()),
5849                ],
5850            );
5851            e.status = 10_000;
5852            e
5853        };
5854        let leader = |head: [f64; 2], tail: [f64; 2]| {
5855            let mut e = entity(
5856                214,
5857                1,
5858                reals(&[1.0, 1.0, 0.5, 0.0, head[0], head[1], tail[0], tail[1]]),
5859            );
5860            e.status = 10_000;
5861            e
5862        };
5863        let witness = |a: [f64; 2], b: [f64; 2]| {
5864            let mut e = entity(106, 40, reals(&[1.0, 2.0, 0.0, a[0], a[1], b[0], b[1]]));
5865            e.status = 10_000;
5866            e
5867        };
5868        let dimension = entity(
5869            216,
5870            0,
5871            vec![
5872                Value::Int(3),
5873                Value::Int(5),
5874                Value::Int(7),
5875                Value::Int(9),
5876                Value::Int(11),
5877            ],
5878        );
5879        let mut sheet_note = note("on the sheet");
5880        sheet_note.status = 0;
5881        let drawing = entity(404, 0, vec![Value::Int(0), Value::Int(1), Value::Int(13)]);
5882        let deck = file(vec![
5883            (1, dimension),
5884            (3, note("2X 25.40")),
5885            (5, leader([0.0, 11.0], [8.0, 11.0])),
5886            (7, leader([25.4, 11.0], [17.0, 11.0])),
5887            (9, witness([0.0, 0.0], [0.0, 12.0])),
5888            (11, witness([25.4, 0.0], [25.4, 12.0])),
5889            (13, sheet_note),
5890            (15, drawing),
5891        ]);
5892        let mut reader = reader(&deck);
5893        let (callouts, dimensions) = reader.annotations();
5894        assert!(callouts.is_empty(), "the sheet's note stays on the sheet");
5895        let [(callout, dimension)] = dimensions.as_slice() else {
5896            panic!("one dimension, found {}", dimensions.len());
5897        };
5898        assert_eq!(callout.name, "2X 25.40");
5899        assert_eq!(
5900            callout.polylines.len(),
5901            4,
5902            "two leaders and two witness lines"
5903        );
5904        assert!(
5905            (dimension.values[0] - 25.4).abs() < 1e-12,
5906            "{:?}",
5907            dimension.values
5908        );
5909        assert!(dimension.location);
5910        assert!(!reader.visited.contains_key(&13));
5911    }
5912
5913    /// A dimension's matrix carries the leaders it owns physically on after
5914    /// their own (IGES 5.3, section 3.2.3, table 4): lifted 5 along z, its
5915    /// physical leader draws at z = 5, and the leader it owns only
5916    /// logically (02) where it stands, at z = 0.
5917    #[test]
5918    fn a_dimension_s_matrix_carries_its_leaders() {
5919        let leader = |status: i64, head: [f64; 2], tail: [f64; 2]| {
5920            let mut e = entity(
5921                214,
5922                1,
5923                reals(&[1.0, 1.0, 0.5, 0.0, head[0], head[1], tail[0], tail[1]]),
5924            );
5925            e.status = status;
5926            e
5927        };
5928        let mut dimension = entity(
5929            216,
5930            0,
5931            vec![
5932                Value::Int(0),
5933                Value::Int(3),
5934                Value::Int(5),
5935                Value::Int(0),
5936                Value::Int(0),
5937            ],
5938        );
5939        dimension.transform = 7;
5940        let mut lift = entity(
5941            124,
5942            0,
5943            reals(&[1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 5.0]),
5944        );
5945        lift.status = 10_000;
5946        let deck = file(vec![
5947            (1, dimension),
5948            (3, leader(10_000, [0.0, 11.0], [8.0, 11.0])),
5949            (5, leader(20_000, [25.4, 11.0], [17.0, 11.0])),
5950            (7, lift),
5951        ]);
5952        let mut reader = reader(&deck);
5953        let (callouts, dimensions) = reader.annotations();
5954        assert!(dimensions.is_empty() && callouts.len() == 1);
5955        let at = |x: f64, y: f64, z: f64| Point::new(x, y, z);
5956        assert_eq!(
5957            callouts[0].polylines,
5958            vec![
5959                vec![at(0.0, 11.0, 5.0), at(8.0, 11.0, 5.0)],
5960                vec![at(25.4, 11.0, 0.0), at(17.0, 11.0, 0.0)],
5961            ]
5962        );
5963    }
5964
5965    /// Two instances of one subfigure: a ten-square trimmed plane placed at
5966    /// x = 100, and at y = 50 twice the size. Each is the definition's face
5967    /// under its own placement, and the definition is built once.
5968    #[test]
5969    fn subfigure_instances_place_their_definition() {
5970        let dependent = |mut e: Entity| {
5971            e.status = 10_000;
5972            e
5973        };
5974        let square = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
5975        let mut entities = vec![(1, dependent(entity(108, 0, reals(&[0.0, 0.0, 1.0, 0.0]))))];
5976        let mut sides = Vec::new();
5977        for i in 0..4 {
5978            let (a, b) = (square[i], square[(i + 1) % 4]);
5979            let de = 3 + 2 * i64::try_from(i).unwrap();
5980            entities.push((de, dependent(line([a[0], a[1], 0.0], [b[0], b[1], 0.0]))));
5981            sides.push(Value::Int(de));
5982        }
5983        let mut composite = vec![Value::Int(4)];
5984        composite.extend(sides);
5985        entities.push((11, dependent(entity(102, 0, composite))));
5986        entities.push((
5987            13,
5988            dependent(entity(
5989                144,
5990                0,
5991                vec![Value::Int(1), Value::Int(1), Value::Int(0), Value::Int(11)],
5992            )),
5993        ));
5994        entities.push((
5995            15,
5996            entity(
5997                308,
5998                0,
5999                vec![
6000                    Value::Int(0),
6001                    Value::Text("tile".to_owned()),
6002                    Value::Int(1),
6003                    Value::Int(13),
6004                ],
6005            ),
6006        ));
6007        entities.push((
6008            17,
6009            entity(
6010                408,
6011                0,
6012                vec![
6013                    Value::Int(15),
6014                    Value::Real(100.0),
6015                    Value::Real(0.0),
6016                    Value::Real(0.0),
6017                    Value::Real(1.0),
6018                ],
6019            ),
6020        ));
6021        entities.push((
6022            19,
6023            entity(
6024                408,
6025                0,
6026                vec![
6027                    Value::Int(15),
6028                    Value::Real(0.0),
6029                    Value::Real(50.0),
6030                    Value::Real(0.0),
6031                    Value::Real(2.0),
6032                ],
6033            ),
6034        ));
6035        let deck = file(entities);
6036        let mut reader = reader(&deck);
6037        let Placed { solids, sheets, .. } = reader.subfigure_instances(T).unwrap();
6038        assert!(solids.is_empty());
6039        assert_eq!(sheets.len(), 2);
6040        let deflection = ogeom_mesh::Deflection::default();
6041        let mut measured: Vec<(f64, Point)> = sheets
6042            .iter()
6043            .map(|sheet| {
6044                let props =
6045                    ogeom_algo::surface_properties(&reader.model, sheet, deflection, T).unwrap();
6046                (props.mass, props.centre)
6047            })
6048            .collect();
6049        measured.sort_by(|a, b| a.0.total_cmp(&b.0));
6050        assert!((measured[0].0 - 100.0).abs() < 1e-9, "{measured:?}");
6051        assert!(measured[0].1.distance(Point::new(105.0, 5.0, 0.0)) < 1e-9);
6052        assert!((measured[1].0 - 400.0).abs() < 1e-9, "{measured:?}");
6053        assert!(measured[1].1.distance(Point::new(10.0, 60.0, 0.0)) < 1e-9);
6054        // One definition, one face node under two placements.
6055        let face_of = |shape: &Shape| {
6056            ogeom_topo::explore(
6057                &reader.model,
6058                shape,
6059                ogeom_topo::Filter::OfType(ogeom_topo::ShapeType::Face),
6060            )
6061            .unwrap()
6062            .remove(0)
6063        };
6064        assert_eq!(face_of(&sheets[0]).node(), face_of(&sheets[1]).node());
6065    }
6066
6067    /// Levels and groups are layers: each face on its level's layer, and a
6068    /// group's members on the group's own.
6069    #[test]
6070    fn levels_and_groups_read_as_layers() {
6071        let square = |z: f64, base: i64| -> Vec<(i64, Entity)> {
6072            let dependent = |mut e: Entity| {
6073                e.status = 10_000;
6074                e
6075            };
6076            let corners = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
6077            let mut out = vec![(base, dependent(entity(108, 0, reals(&[0.0, 0.0, 1.0, z]))))];
6078            let mut sides = vec![Value::Int(4)];
6079            for i in 0..4 {
6080                let (a, b) = (corners[i], corners[(i + 1) % 4]);
6081                let de = base + 2 + 2 * i64::try_from(i).unwrap();
6082                out.push((de, dependent(line([a[0], a[1], z], [b[0], b[1], z]))));
6083                sides.push(Value::Int(de));
6084            }
6085            out.push((base + 10, dependent(entity(102, 0, sides))));
6086            out
6087        };
6088        let mut entities = square(0.0, 1);
6089        entities.extend(square(5.0, 21));
6090        let mut low = entity(
6091            144,
6092            0,
6093            vec![Value::Int(1), Value::Int(1), Value::Int(0), Value::Int(11)],
6094        );
6095        low.level = 3;
6096        let mut high = entity(
6097            144,
6098            0,
6099            vec![Value::Int(21), Value::Int(1), Value::Int(0), Value::Int(31)],
6100        );
6101        high.level = 7;
6102        entities.push((41, low));
6103        entities.push((43, high));
6104        let mut group = entity(402, 7, vec![Value::Int(1), Value::Int(43)]);
6105        group.label = "LID".to_owned();
6106        entities.push((45, group));
6107        let deck = file(entities);
6108        let mut reader = reader(&deck);
6109        let low_face = reader.face(41).unwrap();
6110        let high_face = reader.face(43).unwrap();
6111        let built = vec![(41, low_face.clone()), (43, high_face.clone())];
6112        let mut document = ogeom_doc::Document::over(std::mem::take(&mut reader.model));
6113        let mut report = IgesReport::default();
6114        layers(&deck, &mut document, &built, &mut report);
6115        let names_of = |shape: &Shape| -> Vec<String> {
6116            let mut names: Vec<String> = document
6117                .layers_of(shape)
6118                .iter()
6119                .map(|id| document.layer(*id).unwrap().name.clone())
6120                .collect();
6121            names.sort();
6122            names
6123        };
6124        assert_eq!(names_of(&low_face), vec!["level 3".to_owned()]);
6125        assert_eq!(
6126            names_of(&high_face),
6127            vec!["LID".to_owned(), "level 7".to_owned()]
6128        );
6129    }
6130
6131    /// Constructive solids against their closed forms: a block drilled by a
6132    /// cylinder through a boolean tree, a wedge, a cone frustum, an
6133    /// ellipsoid, a half-turn of revolution, an extruded square, and an
6134    /// assembly placing one block twice.
6135    #[test]
6136    fn constructive_solids_read_as_their_volumes() {
6137        let pi = core::f64::consts::PI;
6138        let square = |base: i64, corners: [[f64; 3]; 4]| -> Vec<(i64, Entity)> {
6139            let mut out = Vec::new();
6140            let mut sides = vec![Value::Int(4)];
6141            for i in 0..4 {
6142                let de = base + 2 * i64::try_from(i).unwrap();
6143                out.push((de, line(corners[i], corners[(i + 1) % 4])));
6144                sides.push(Value::Int(de));
6145            }
6146            out.push((base + 8, entity(102, 0, sides)));
6147            out
6148        };
6149        let mut entities = vec![
6150            // Block 20³ at the origin.
6151            (
6152                1,
6153                entity(
6154                    150,
6155                    0,
6156                    reals(&[
6157                        20.0, 20.0, 20.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
6158                    ]),
6159                ),
6160            ),
6161            // Cylinder r 5, h 40, up the block's middle from below.
6162            (
6163                3,
6164                entity(
6165                    154,
6166                    0,
6167                    reals(&[40.0, 5.0, 10.0, 10.0, -10.0, 0.0, 0.0, 1.0]),
6168                ),
6169            ),
6170            (
6171                5,
6172                entity(
6173                    180,
6174                    0,
6175                    vec![Value::Int(3), Value::Int(-1), Value::Int(-3), Value::Int(3)],
6176                ),
6177            ),
6178            // Wedge 10 × 5 × 4, narrowing to 6 along x at y = 5.
6179            (
6180                7,
6181                entity(
6182                    152,
6183                    0,
6184                    reals(&[
6185                        10.0, 5.0, 4.0, 6.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
6186                    ]),
6187                ),
6188            ),
6189            // Frustum h 10 from r 5 to r 2.
6190            (
6191                9,
6192                entity(
6193                    156,
6194                    0,
6195                    reals(&[10.0, 5.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]),
6196                ),
6197            ),
6198            // Ellipsoid 3 × 2 × 1.
6199            (
6200                11,
6201                entity(
6202                    168,
6203                    0,
6204                    reals(&[3.0, 2.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]),
6205                ),
6206            ),
6207            // Half a turn of the rectangle x 2..4, z 0..3 about z.
6208            (
6209                13,
6210                entity(
6211                    162,
6212                    0,
6213                    vec![
6214                        Value::Int(29),
6215                        Value::Real(0.5),
6216                        Value::Real(0.0),
6217                        Value::Real(0.0),
6218                        Value::Real(0.0),
6219                        Value::Real(0.0),
6220                        Value::Real(0.0),
6221                        Value::Real(1.0),
6222                    ],
6223                ),
6224            ),
6225            // The square 0..2 in xy extruded 5 up z.
6226            (
6227                15,
6228                entity(
6229                    164,
6230                    0,
6231                    vec![
6232                        Value::Int(49),
6233                        Value::Real(5.0),
6234                        Value::Real(0.0),
6235                        Value::Real(0.0),
6236                        Value::Real(1.0),
6237                    ],
6238                ),
6239            ),
6240            // The block twice: as it stands, and moved 100 along x.
6241            (
6242                17,
6243                entity(
6244                    124,
6245                    0,
6246                    reals(&[1.0, 0.0, 0.0, 100.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0]),
6247                ),
6248            ),
6249            (
6250                19,
6251                entity(
6252                    184,
6253                    0,
6254                    vec![
6255                        Value::Int(2),
6256                        Value::Int(1),
6257                        Value::Int(1),
6258                        Value::Int(0),
6259                        Value::Int(17),
6260                    ],
6261                ),
6262            ),
6263        ];
6264        entities.extend(square(
6265            21,
6266            [
6267                [2.0, 0.0, 0.0],
6268                [4.0, 0.0, 0.0],
6269                [4.0, 0.0, 3.0],
6270                [2.0, 0.0, 3.0],
6271            ],
6272        ));
6273        entities.extend(square(
6274            41,
6275            [
6276                [0.0, 0.0, 0.0],
6277                [2.0, 0.0, 0.0],
6278                [2.0, 2.0, 0.0],
6279                [0.0, 2.0, 0.0],
6280            ],
6281        ));
6282        let deck = file(entities);
6283        let mut reader = reader(&deck);
6284        let volume = |reader: &Reader<'_>, shape: &Shape| {
6285            ogeom_algo::volume_properties(
6286                &reader.model,
6287                shape,
6288                ogeom_mesh::Deflection::with_chord(1e-3).unwrap(),
6289                T,
6290            )
6291            .unwrap()
6292            .mass
6293        };
6294        for (de, want, within) in [
6295            (5, 8000.0 - pi * 25.0 * 20.0, 1e-6),
6296            (7, (10.0 + 6.0) / 2.0 * 5.0 * 4.0, 1e-9),
6297            (9, pi * 10.0 / 3.0 * (25.0 + 10.0 + 4.0), 1e-6),
6298            // Exact on its equation (below); the mesh measuring the volume
6299            // falls short by the chord over the area, a part in a thousand.
6300            (11, 4.0 / 3.0 * pi * 6.0, 2e-3),
6301            (13, 0.5 * pi * (16.0 - 4.0) * 3.0, 1e-6),
6302            (15, 20.0, 1e-9),
6303        ] {
6304            let solids = reader.csg_solids(de).unwrap();
6305            let [solid] = solids.as_slice() else {
6306                panic!("D{de} is one solid");
6307            };
6308            let got = volume(&reader, solid);
6309            assert!(
6310                (got - want).abs() <= within * want.max(1.0),
6311                "D{de}: {got} against {want}"
6312            );
6313        }
6314        // The ellipsoid's surface on its own equation, at points spread
6315        // over every face.
6316        let ellipsoid = reader.csg_solids(11).unwrap().remove(0);
6317        for face in ogeom_topo::explore(
6318            &reader.model,
6319            &ellipsoid,
6320            ogeom_topo::Filter::OfType(ogeom_topo::ShapeType::Face),
6321        )
6322        .unwrap()
6323        {
6324            let data = reader
6325                .model
6326                .node(&face)
6327                .unwrap()
6328                .data()
6329                .as_face()
6330                .unwrap()
6331                .clone();
6332            let surface = reader
6333                .model
6334                .geometry()
6335                .surface(data.surface)
6336                .unwrap()
6337                .clone();
6338            let ((u0, u1), (v0, v1)) = ogeom_geom::Surface::domain(&surface);
6339            for i in 0..=6 {
6340                for j in 0..=6 {
6341                    let (u, v) = (
6342                        u0 + (u1 - u0) * f64::from(i) / 6.0,
6343                        v0 + (v1 - v0) * f64::from(j) / 6.0,
6344                    );
6345                    let p = ogeom_geom::Surface::point_at(&surface, u, v, T).unwrap();
6346                    let residual = p.x * p.x / 9.0 + p.y * p.y / 4.0 + p.z * p.z - 1.0;
6347                    assert!(residual.abs() < 1e-9, "off the ellipsoid: {p:?}");
6348                }
6349            }
6350        }
6351        let placed = reader.csg_solids(19).unwrap();
6352        assert_eq!(placed.len(), 2);
6353        let centres: Vec<Point> = placed
6354            .iter()
6355            .map(|s| {
6356                ogeom_algo::volume_properties(
6357                    &reader.model,
6358                    s,
6359                    ogeom_mesh::Deflection::default(),
6360                    T,
6361                )
6362                .unwrap()
6363                .centre
6364            })
6365            .collect();
6366        assert!(
6367            centres
6368                .iter()
6369                .any(|c| c.distance(Point::new(10.0, 10.0, 10.0)) < 1e-9)
6370        );
6371        assert!(
6372            centres
6373                .iter()
6374                .any(|c| c.distance(Point::new(110.0, 10.0, 10.0)) < 1e-9)
6375        );
6376    }
6377}