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 parity ledger's `io.iges` row for the whole
18//! picture.
19
20use super::parse::{Entity, File};
21use ogeom_algo::{make_edge_between, make_solid, make_vertex, sew};
22use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
23use ogeom_geom::Curve3d as _;
24use ogeom_geom::Transformable as _;
25use ogeom_geom::{
26    BSplineCurve, BSplineSurface, CircleCurve, ConeSurface, Curve, CylinderSurface, EllipseCurve,
27    ExtrusionSurface, HyperbolaCurve, LineCurve, OffsetCurve, OffsetSurface, ParabolaCurve,
28    PlaneSurface, RevolutionSurface, SphereSurface, SurfaceGeometry, TorusSurface, TrimmedCurve,
29};
30use ogeom_math::{
31    Circle, Cone, ControlGrid, Cylinder, Direction, Ellipse, Frame, Hyperbola, KnotVector, Matrix3,
32    Parabola, Plane, Point, Sphere, Torus, Transform, Vector, Weighted,
33};
34use ogeom_topo::{Model, Shape};
35use std::collections::{BTreeMap, HashMap};
36
37/// How far an unbounded plane or quadric extends past anything the file uses
38/// (the same convention the STEP reader states: a face's trim is its wires,
39/// and the surface's domain is only a parameter window).
40const SURFACE_EXTENT: f64 = 1e5;
41
42/// The constructive solid entities: the primitives, the solids of revolution
43/// and extrusion, the ellipsoid, the boolean tree, the solid assembly and
44/// the solid instance.
45const CSG_KINDS: [i64; 12] = [150, 152, 154, 156, 158, 160, 162, 164, 168, 180, 184, 430];
46
47/// What an import brought in, and what it left behind.
48#[derive(Debug, Default)]
49pub struct IgesReport {
50    /// Millimetres per file unit, as the global section states it.
51    pub scale_mm: f64,
52    /// Entity types the reader never visited, with counts, keyed as
53    /// `"type NNN"` or `"type NNN form F"`. Annotation and drafting land
54    /// here by design; geometry landing here is a gap worth reading about.
55    pub skipped: BTreeMap<String, usize>,
56    /// Everything that imported less than perfectly, one line each.
57    pub warnings: Vec<String>,
58}
59
60/// A read IGES file: the document, the shapes found, and the report.
61#[derive(Debug)]
62pub struct IgesImport {
63    /// The document everything was built into.
64    pub document: ogeom_doc::Document,
65    /// One shape per manifold solid, then one per surface group that sewed
66    /// closed.
67    pub solids: Vec<Shape>,
68    /// Sewn shells and loose faces that do not enclose a volume.
69    pub sheets: Vec<Shape>,
70    /// What happened along the way.
71    pub report: IgesReport,
72}
73
74/// The pieces an edge-list entry resolves to.
75type BuiltEdge = (Shape, Curve, (f64, f64));
76
77/// Read an IGES file's geometry.
78///
79/// # Errors
80///
81/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
82/// deck does not parse, its units are unreadable, or it contains nothing this
83/// reader translates into shapes. Individual entities that fail to translate
84/// become warnings and skipped counts rather than errors; the report says
85/// exactly what was compromised.
86pub fn read_iges(text: &str, tol: Tolerances) -> OgeomResult<IgesImport> {
87    let file = super::parse::parse(text)?;
88    let Some(scale_mm) = file.scale_mm() else {
89        ogeom_bail!(
90            Construction,
91            "IGES global section names units this reader cannot convert to millimetres"
92        );
93    };
94    let mut reader = Reader {
95        file: &file,
96        model: Model::new(),
97        report: IgesReport {
98            scale_mm,
99            ..IgesReport::default()
100        },
101        visited: BTreeMap::new(),
102        vertices: HashMap::new(),
103        edges: HashMap::new(),
104        vertex_misses: (0, 0.0),
105        tol,
106    };
107
108    // Solids first: every 186, in directory order.
109    let mut solids: Vec<(i64, Shape)> = Vec::new();
110    let solid_des: Vec<i64> = file
111        .entities
112        .iter()
113        .filter(|(_, e)| e.kind == 186)
114        .map(|(de, _)| *de)
115        .collect();
116    let total = solid_des.len() as u64;
117    for (done, de) in solid_des.into_iter().enumerate() {
118        ogeom_core::progress::checkpoint()?;
119        ogeom_core::progress::stage_at("iges: solid", done as u64 + 1, total);
120        match reader.manifold_solid(de) {
121            Ok(solid) => solids.push((de, solid)),
122            Err(e) => reader
123                .report
124                .warnings
125                .push(format!("D{de}: manifold solid failed to build: {e}")),
126        }
127    }
128
129    // Then the surface file: every *independent* trimmed or bounded surface
130    // becomes a face; the faces sew; closed shells become solids. Subordinate
131    // entities belong to something else and are not top-level geometry; the
132    // subordinate switch is the second two-digit field of the status word.
133    let mut faces = Vec::new();
134    // Which directory entry built which shape, for levels and groups.
135    let mut built_from: Vec<(i64, Shape)> = solids.clone();
136    let face_des: Vec<i64> = file
137        .entities
138        .iter()
139        .filter(|(de, e)| {
140            matches!(e.kind, 143 | 144)
141                && (e.status / 10_000) % 100 == 0
142                && !reader.visited.contains_key(de)
143        })
144        .map(|(de, _)| *de)
145        .collect();
146    let total = face_des.len() as u64;
147    for (done, de) in face_des.into_iter().enumerate() {
148        ogeom_core::progress::checkpoint()?;
149        ogeom_core::progress::stage_at("iges: face", done as u64 + 1, total);
150        match reader.face(de) {
151            Ok(face) => {
152                built_from.push((de, face.clone()));
153                faces.push(face);
154            }
155            Err(e) => reader
156                .report
157                .warnings
158                .push(format!("D{de}: trimmed surface failed to build: {e}")),
159        }
160    }
161    let mut sheets = Vec::new();
162    if !faces.is_empty() {
163        let sewn = sew(&mut reader.model, &faces, tol)?;
164        for shell in &sewn.shells {
165            if ogeom_algo::is_shell_closed(&reader.model, shell)? {
166                let solid = make_solid(&mut reader.model, std::slice::from_ref(shell))?.shape;
167                solids.push((0, solid));
168            } else {
169                sheets.push(shell.clone());
170            }
171        }
172    }
173
174    // Constructive solids: every independent primitive, boolean tree,
175    // assembly and instance.
176    let csg_des: Vec<i64> = file
177        .entities
178        .iter()
179        .filter(|(de, e)| {
180            CSG_KINDS.contains(&e.kind)
181                && (e.status / 10_000) % 100 == 0
182                && !reader.visited.contains_key(de)
183        })
184        .map(|(de, _)| *de)
185        .collect();
186    for de in csg_des {
187        match reader.csg_solids(de) {
188            Ok(built) => {
189                for solid in built {
190                    built_from.push((de, solid.clone()));
191                    solids.push((de, solid));
192                }
193            }
194            Err(e) => reader
195                .report
196                .warnings
197                .push(format!("D{de}: constructive solid failed to build: {e}")),
198        }
199    }
200
201    // Subfigure instances: each independent 408 places its definition's
202    // solids and trimmed surfaces, built once and shared by every instance.
203    let (placed_solids, placed_sheets, placed_from) = reader.subfigure_instances(tol)?;
204    solids.extend(placed_solids.into_iter().map(|s| (0, s)));
205    sheets.extend(placed_sheets);
206    built_from.extend(placed_from);
207
208    if solids.is_empty() && sheets.is_empty() {
209        // A deck that *had* candidates which all failed is a different
210        // refusal from one with nothing to try, and the warnings say why.
211        if reader.report.warnings.is_empty() {
212            ogeom_bail!(
213                Construction,
214                "the IGES file contains no manifold solid and no independent \
215                 trimmed surface this reader translates"
216            );
217        }
218        ogeom_bail!(
219            Construction,
220            "every shape in the IGES file failed to build: {}",
221            reader.report.warnings.join("; ")
222        );
223    }
224
225    if reader.vertex_misses.0 > 0 {
226        let (count, worst) = reader.vertex_misses;
227        reader.report.warnings.push(format!(
228            "{count} vertices sat off the curve ends they bound, by up to {worst:.2e}; \
229             their tolerances grew to say so"
230        ));
231    }
232    let (callouts, dimensions) = reader.annotations();
233    // Groups and level lists become layers once the document stands.
234    for (de, entity) in &file.entities {
235        if (entity.kind == 402 && matches!(entity.form, 1 | 7 | 14 | 15))
236            || (entity.kind == 406 && entity.form == 1)
237        {
238            reader.visited.insert(*de, ());
239        }
240    }
241    // Everything never visited, counted by type and form.
242    for (de, entity) in &file.entities {
243        if !reader.visited.contains_key(de) {
244            let key = if entity.form == 0 {
245                format!("type {}", entity.kind)
246            } else {
247                format!("type {} form {}", entity.kind, entity.form)
248            };
249            *reader.report.skipped.entry(key).or_default() += 1;
250        }
251    }
252    let mut document = reader.document(&solids, &sheets);
253    {
254        let pmi = document.pmi_mut();
255        let base = pmi.dimensions.len();
256        for (i, dimension) in dimensions.into_iter().enumerate() {
257            let (callout, dimension) = dimension;
258            pmi.dimensions.push(dimension);
259            let mut callout = callout;
260            callout.annotates = Some(ogeom_doc::Annotated::Dimension(base + i));
261            pmi.callouts.push(callout);
262        }
263        pmi.callouts.extend(callouts);
264    }
265    layers(&file, &mut document, &built_from, &mut reader.report);
266    let solids = solids.into_iter().map(|(_, s)| s).collect();
267    Ok(IgesImport {
268        document,
269        solids,
270        sheets,
271        report: reader.report,
272    })
273}
274
275struct Reader<'a> {
276    file: &'a File,
277    model: Model,
278    report: IgesReport,
279    /// Every directory entry the reader consumed, for the skipped table.
280    visited: BTreeMap<i64, ()>,
281    /// Vertices by (vertex-list DE, 1-based index), shared, which is what
282    /// lets a closed shell close.
283    vertices: HashMap<(i64, i64), Shape>,
284    /// Edges by (edge-list DE, 1-based index), for the same reason.
285    edges: HashMap<(i64, i64), BuiltEdge>,
286    /// Vertices a curve end missed by more than the confusion tolerance
287    /// and that widened to cover it: how many, and the widest miss.
288    vertex_misses: (usize, f64),
289    tol: Tolerances,
290}
291
292impl<'a> Reader<'a> {
293    fn entity(&mut self, de: i64) -> OgeomResult<&'a Entity> {
294        let Some(entity) = self.file.entity(de) else {
295            ogeom_bail!(Construction, "IGES pointer D{de} names no entity");
296        };
297        self.visited.insert(de.abs(), ());
298        Ok(entity)
299    }
300
301    /// The model-space transform an entity carries, identity when none. A
302    /// transformation entity may itself be transformed; that composes.
303    fn placement(&mut self, entity: &Entity) -> OgeomResult<Transform> {
304        if entity.transform == 0 {
305            return Ok(Transform::IDENTITY);
306        }
307        let de = entity.transform;
308        let t = self.entity(de)?;
309        if t.kind != 124 {
310            ogeom_bail!(
311                Construction,
312                "D{de}: a transformation pointer names a type {} entity",
313                t.kind
314            );
315        }
316        let v = |i: usize| t.at(i).real();
317        let s = self.report.scale_mm;
318        let linear = Matrix3::new([[v(0), v(1), v(2)], [v(4), v(5), v(6)], [v(8), v(9), v(10)]]);
319        let translation = Vector::new(v(3) * s, v(7) * s, v(11) * s);
320        let m = Transform::from_parts(linear, 1.0, translation, self.tol.angular())?;
321        if t.transform != 0 {
322            let outer = self.placement(t)?;
323            return Ok(outer * m);
324        }
325        Ok(m)
326    }
327
328    fn point3(&self, e: &Entity, i: usize) -> Point {
329        let s = self.report.scale_mm;
330        Point::new(
331            e.at(i).real() * s,
332            e.at(i + 1).real() * s,
333            e.at(i + 2).real() * s,
334        )
335    }
336
337    /// A model-space curve with the range its own definition covers.
338    fn curve(&mut self, de: i64) -> OgeomResult<(Curve, (f64, f64))> {
339        let entity = self.entity(de)?;
340        let scale = self.report.scale_mm;
341        let (curve, range) = match entity.kind {
342            110 => {
343                let a = self.point3(entity, 0);
344                let b = self.point3(entity, 3);
345                let line = LineCurve::segment(a, b, self.tol)?;
346                (Curve::from(line), (0.0, a.distance(b)))
347            }
348            100 => {
349                // Centre, start and end in the definition plane at z = zt;
350                // the arc runs counter-clockwise from start to end.
351                let zt = entity.at(0).real() * scale;
352                let c = Point::new(entity.at(1).real() * scale, entity.at(2).real() * scale, zt);
353                let s = Point::new(entity.at(3).real() * scale, entity.at(4).real() * scale, zt);
354                let e = Point::new(entity.at(5).real() * scale, entity.at(6).real() * scale, zt);
355                let radius = c.distance(s);
356                let x = Direction::new(s - c, self.tol)?;
357                let frame = Frame::new(c, Direction::Z, x, self.tol)?;
358                let circle = Circle::new(frame, radius, self.tol)?;
359                let to_end = e - c;
360                let ang = to_end
361                    .dot(frame.y().vector())
362                    .atan2(to_end.dot(frame.x().vector()))
363                    .rem_euclid(core::f64::consts::TAU);
364                let sweep = if ang <= self.tol.parametric() {
365                    core::f64::consts::TAU
366                } else {
367                    ang
368                };
369                (Curve::from(CircleCurve::new(circle)), (0.0, sweep))
370            }
371            104 => self.conic(de, entity)?,
372            112 => self.spline_curve(de, entity)?,
373            126 => self.nurbs_curve(de, entity)?,
374            130 => self.offset_curve(de, entity)?,
375            102 => ogeom_bail!(
376                Construction,
377                "D{de}: a composite curve is a sequence, not a curve; the \
378                 caller walks its segments"
379            ),
380            kind => ogeom_bail!(
381                Construction,
382                "D{de}: curve entity type {kind}{} is not translated; see \
383                 docs/PARITY.md, io.iges",
384                super::entity_name(kind).map_or_else(String::new, |n| format!(" ({n})"))
385            ),
386        };
387        // The entity's placement moves the curve into model space.
388        let placement = self.placement(entity)?;
389        if placement == Transform::IDENTITY {
390            Ok((curve, range))
391        } else {
392            Ok((curve.transformed(&placement, self.tol)?, range))
393        }
394    }
395
396    /// Conic arc: `A x² + B xy + C y² + D x + E y + F = 0` in the definition
397    /// plane. The coefficients say which conic it is (both squares one sign
398    /// an ellipse, opposite signs a hyperbola, one square missing a
399    /// parabola), and each translates to its own curve, the arc's ends read
400    /// off the start and terminate points. A conic whose axes turn (`B` not
401    /// zero) is read in the frame turned by `θ`, `tan 2θ = B / (A − C)`,
402    /// where its cross term vanishes, and turned back: the curve is exact
403    /// either way.
404    fn conic(&mut self, de: i64, entity: &Entity) -> OgeomResult<(Curve, (f64, f64))> {
405        let scale = self.report.scale_mm;
406        let (a, b, c, d, e, f) = (
407            entity.at(0).real(),
408            entity.at(1).real(),
409            entity.at(2).real(),
410            entity.at(3).real(),
411            entity.at(4).real(),
412            entity.at(5).real(),
413        );
414        let zt = entity.at(6).real() * scale;
415        let start = Point::new(entity.at(7).real() * scale, entity.at(8).real() * scale, zt);
416        let end = Point::new(
417            entity.at(9).real() * scale,
418            entity.at(10).real() * scale,
419            zt,
420        );
421        if b.abs() > 1e-12 {
422            let theta = 0.5 * b.atan2(a - c);
423            let (sin, cos) = theta.sin_cos();
424            // x = x' cos θ − y' sin θ, y = x' sin θ + y' cos θ.
425            let turned = [
426                a * cos * cos + b * cos * sin + c * sin * sin,
427                a * sin * sin - b * cos * sin + c * cos * cos,
428                d * cos + e * sin,
429                -d * sin + e * cos,
430                f,
431            ];
432            let about = ogeom_math::Axis::new(Point::ORIGIN, Direction::Z);
433            let back = Transform::rotation(about, theta);
434            let into = Transform::rotation(about, -theta);
435            let (curve, range) =
436                self.axis_aligned_conic(de, turned, zt, into.apply(start), into.apply(end))?;
437            return Ok((curve.transformed(&back, self.tol)?, range));
438        }
439        self.axis_aligned_conic(de, [a, c, d, e, f], zt, start, end)
440    }
441
442    /// [`Reader::conic`] once its axes are the definition plane's own:
443    /// `A x² + C y² + D x + E y + F = 0`.
444    fn axis_aligned_conic(
445        &mut self,
446        de: i64,
447        [a, c, d, e, f]: [f64; 5],
448        zt: f64,
449        start: Point,
450        end: Point,
451    ) -> OgeomResult<(Curve, (f64, f64))> {
452        let scale = self.report.scale_mm;
453        // Both squares present and of one sign: an ellipse, its coefficients
454        // made positive.
455        if a != 0.0 && c != 0.0 && (a > 0.0) == (c > 0.0) {
456            let sign = if a > 0.0 { 1.0 } else { -1.0 };
457            return self.ellipse_arc(de, [a, c, d, e, f].map(|k| k * sign), zt, start, end);
458        }
459        if a != 0.0 && c != 0.0 {
460            return Self::hyperbola_arc(de, [a, c, d, e, f], zt, start, end, scale, self.tol);
461        }
462        if (a == 0.0) != (c == 0.0) {
463            return Self::parabola_arc(de, [a, c, d, e, f], zt, start, end, scale, self.tol);
464        }
465        ogeom_bail!(Construction, "D{de}: conic arc coefficients close no conic")
466    }
467
468    /// The ellipse arm of [`Reader::conic`]: `A x² + C y² + D x + E y + F = 0`
469    /// with `A` and `C` positive.
470    fn ellipse_arc(
471        &mut self,
472        de: i64,
473        [a, c, d, e, f]: [f64; 5],
474        zt: f64,
475        start: Point,
476        end: Point,
477    ) -> OgeomResult<(Curve, (f64, f64))> {
478        let scale = self.report.scale_mm;
479        let cx = -d / (2.0 * a);
480        let cy = -e / (2.0 * c);
481        let rhs = a * cx * cx + c * cy * cy - f;
482        let (ra2, rb2) = (rhs / a, rhs / c);
483        if ra2 <= 0.0 || rb2 <= 0.0 {
484            ogeom_bail!(
485                Construction,
486                "D{de}: conic arc coefficients close no ellipse"
487            );
488        }
489        let centre = Point::new(cx * scale, cy * scale, zt);
490        let (rx, ry) = (ra2.sqrt() * scale, rb2.sqrt() * scale);
491        // The ellipse type wants major ≥ minor; when the x semi-axis is the
492        // smaller one, a quarter-turn of the frame swaps the roles.
493        let (frame, major, minor) = if rx >= ry {
494            (
495                Frame::new(centre, Direction::Z, Direction::X, self.tol)?,
496                rx,
497                ry,
498            )
499        } else {
500            (
501                Frame::new(centre, Direction::Z, Direction::Y, self.tol)?,
502                ry,
503                rx,
504            )
505        };
506        let ellipse = Ellipse::new(frame, major, minor, self.tol)?;
507        let angle_of = |p: Point| -> f64 {
508            let local = frame.to_local(p);
509            (local.y / minor)
510                .atan2(local.x / major)
511                .rem_euclid(core::f64::consts::TAU)
512        };
513        let t0 = angle_of(start);
514        let mut t1 = angle_of(end);
515        if t1 <= t0 + self.tol.parametric() {
516            t1 += core::f64::consts::TAU;
517        }
518        Ok((Curve::from(EllipseCurve::new(ellipse)), (t0, t1)))
519    }
520
521    /// The hyperbola arm of [`Reader::conic`]: squares of opposite sign.
522    /// The branch is the one the arc's ends stand on, its parameter the
523    /// natural one, `(a cosh t, b sinh t)`, and the arc runs in increasing
524    /// parameter, the frame turned over where the file's ends run the other
525    /// way.
526    fn hyperbola_arc(
527        de: i64,
528        [a, c, d, e, f]: [f64; 5],
529        zt: f64,
530        start: Point,
531        end: Point,
532        scale: f64,
533        tol: Tolerances,
534    ) -> OgeomResult<(Curve, (f64, f64))> {
535        let cx = -d / (2.0 * a);
536        let cy = -e / (2.0 * c);
537        let rhs = a * cx * cx + c * cy * cy - f;
538        if rhs == 0.0 {
539            ogeom_bail!(
540                Construction,
541                "D{de}: conic arc coefficients close no hyperbola; they cross"
542            );
543        }
544        // Divided through by the right-hand side, the positive square names
545        // the transverse axis.
546        let (pa, pc) = (a / rhs, c / rhs);
547        let (major, minor, along_x) = if pa > 0.0 {
548            ((1.0 / pa).sqrt(), (-1.0 / pc).sqrt(), true)
549        } else {
550            ((1.0 / pc).sqrt(), (-1.0 / pa).sqrt(), false)
551        };
552        let centre = Point::new(cx * scale, cy * scale, zt);
553        let axis = if along_x { Direction::X } else { Direction::Y };
554        // The branch: where the ends stand along the transverse axis.
555        let side = (start - centre).dot(axis.vector());
556        let x = if side >= 0.0 {
557            axis
558        } else {
559            Direction::new(-axis.vector(), tol)?
560        };
561        let build = |z: Direction| -> OgeomResult<(Curve, (f64, f64))> {
562            let frame = Frame::new(centre, z, x, tol)?;
563            let (major, minor) = (major * scale, minor * scale);
564            let hyperbola = Hyperbola::new(frame, major, minor, tol)?;
565            let t_of = |p: Point| (frame.to_local(p).y / minor).asinh();
566            let (t0, t1) = (t_of(start), t_of(end));
567            let extent = t0.abs().max(t1.abs()).max(1e-3) * 1.5;
568            Ok((
569                Curve::from(HyperbolaCurve::new(hyperbola, extent)?),
570                (t0, t1),
571            ))
572        };
573        let (curve, (t0, t1)) = build(Direction::Z)?;
574        if t1 > t0 {
575            return Ok((curve, (t0, t1)));
576        }
577        // Turned over: the same branch, the parameter running the other way.
578        let (curve, (t0, t1)) = build(Direction::new(-Direction::Z.vector(), tol)?)?;
579        Ok((curve, (t0, t1)))
580    }
581
582    /// The parabola arm of [`Reader::conic`]: one square missing. The axis
583    /// is the missing square's direction; the parameter runs along the
584    /// other, as this vocabulary's parabola does.
585    fn parabola_arc(
586        de: i64,
587        [a, c, d, e, f]: [f64; 5],
588        zt: f64,
589        start: Point,
590        end: Point,
591        scale: f64,
592        tol: Tolerances,
593    ) -> OgeomResult<(Curve, (f64, f64))> {
594        // `A x² + D x + E y + F = 0` opens along y; `C y² + D x + E y + F = 0`
595        // along x. Either is `axis = apex + k (across - apex)²`.
596        let (apex_across, apex_along, k, along) = if c == 0.0 {
597            if e == 0.0 {
598                ogeom_bail!(
599                    Construction,
600                    "D{de}: conic arc coefficients close no parabola"
601                );
602            }
603            let x0 = -d / (2.0 * a);
604            let y0 = -(a * x0 * x0 + d * x0 + f) / e;
605            (x0, y0, -a / e, Direction::Y)
606        } else {
607            if d == 0.0 {
608                ogeom_bail!(
609                    Construction,
610                    "D{de}: conic arc coefficients close no parabola"
611                );
612            }
613            let y0 = -e / (2.0 * c);
614            let x0 = -(c * y0 * y0 + e * y0 + f) / d;
615            (y0, x0, -c / d, Direction::X)
616        };
617        let apex = if c == 0.0 {
618            Point::new(apex_across * scale, apex_along * scale, zt)
619        } else {
620            Point::new(apex_along * scale, apex_across * scale, zt)
621        };
622        // The axis points the way the parabola opens.
623        let x = if k >= 0.0 {
624            along
625        } else {
626            Direction::new(-along.vector(), tol)?
627        };
628        // `axis = k across²` against `axis = across² / (4 f)`.
629        let focal = scale / (4.0 * k.abs());
630        let build = |z: Direction| -> OgeomResult<(Curve, (f64, f64))> {
631            let frame = Frame::new(apex, z, x, tol)?;
632            let parabola = Parabola::new(frame, focal, tol)?;
633            let t_of = |p: Point| frame.to_local(p).y;
634            let (t0, t1) = (t_of(start), t_of(end));
635            let extent = t0.abs().max(t1.abs()).max(1e-3) * 1.5;
636            Ok((Curve::from(ParabolaCurve::new(parabola, extent)?), (t0, t1)))
637        };
638        let (curve, (t0, t1)) = build(Direction::Z)?;
639        if t1 > t0 {
640            return Ok((curve, (t0, t1)));
641        }
642        let (curve, (t0, t1)) = build(Direction::new(-Direction::Z.vector(), tol)?)?;
643        Ok((curve, (t0, t1)))
644    }
645
646    /// Offset curve (130): a base curve displaced perpendicular to a
647    /// reference direction. The file displaces along the reference crossed
648    /// with the tangent; this vocabulary's offset runs along the tangent
649    /// crossed with the reference, so the distance flips sign. A constant
650    /// distance is the exact offset curve. A distance that varies (type 2,
651    /// linearly with arc length from `D1` at `TD1` to `D2` at `TD2`; type 3,
652    /// as a coordinate of another curve at the same parameter) has no
653    /// closed form, and the displaced points are fitted, same-parameter
654    /// with the base curve, to a hundredth of a micron.
655    fn offset_curve(&mut self, de: i64, entity: &Entity) -> OgeomResult<(Curve, (f64, f64))> {
656        let scale = self.report.scale_mm;
657        let kind = entity.at(1).int();
658        let (d1, d2) = (entity.at(5).real(), entity.at(7).real());
659        if kind != 1 || (d1 - d2).abs() > 1e-12 {
660            return self.varying_offset(de, entity, kind);
661        }
662        let (basis, range) = self.curve(entity.at(0).int())?;
663        let reference = Direction::from_coords(
664            entity.at(9).real(),
665            entity.at(10).real(),
666            entity.at(11).real(),
667            self.tol,
668        )?;
669        let (tt1, tt2) = (entity.at(12).real(), entity.at(13).real());
670        let range = if tt2 > tt1 { (tt1, tt2) } else { range };
671        let offset = OffsetCurve::new(basis, -d1 * scale, reference)?;
672        Ok((Curve::Offset(Box::new(offset)), range))
673    }
674
675    /// The varying arm of [`Reader::offset_curve`].
676    fn varying_offset(
677        &mut self,
678        de: i64,
679        entity: &Entity,
680        kind: i64,
681    ) -> OgeomResult<(Curve, (f64, f64))> {
682        let scale = self.report.scale_mm;
683        let (basis, range) = self.curve(entity.at(0).int())?;
684        let reference = Direction::from_coords(
685            entity.at(9).real(),
686            entity.at(10).real(),
687            entity.at(11).real(),
688            self.tol,
689        )?
690        .vector();
691        let (tt1, tt2) = (entity.at(12).real(), entity.at(13).real());
692        let range = if tt2 > tt1 { (tt1, tt2) } else { range };
693        const SAMPLES: usize = 400;
694        let parameters: Vec<f64> = (0..=SAMPLES)
695            .map(|i| {
696                #[allow(clippy::cast_precision_loss)]
697                let f = i as f64 / SAMPLES as f64;
698                range.0 + (range.1 - range.0) * f
699            })
700            .collect();
701        let distance: Box<dyn Fn(usize, f64) -> OgeomResult<f64>> = match kind {
702            2 => {
703                // Linear in arc length along the base, from TD1 to TD2.
704                let (d1, td1, d2, td2) = (
705                    entity.at(5).real(),
706                    entity.at(6).real(),
707                    entity.at(7).real(),
708                    entity.at(8).real(),
709                );
710                let mut lengths = vec![0.0_f64];
711                let mut last = basis.point_at(parameters[0], self.tol)?;
712                for &t in &parameters[1..] {
713                    let p = basis.point_at(t, self.tol)?;
714                    let held = lengths[lengths.len() - 1];
715                    lengths.push(held + last.distance(p) / scale);
716                    last = p;
717                }
718                let span = td2 - td1;
719                Box::new(move |i, _| {
720                    let f = if span.abs() > 0.0 {
721                        (lengths[i] - td1) / span
722                    } else {
723                        0.0
724                    };
725                    Ok(d1 + (d2 - d1) * f)
726                })
727            }
728            3 => {
729                // A coordinate (TT: 1 x, 2 y, 3 z) of another curve at the
730                // same parameter.
731                let (function, _) = self.curve(entity.at(2).int())?;
732                let which = entity.at(3).int();
733                let tol = self.tol;
734                Box::new(move |_, t| {
735                    let p = function.point_at(t, tol)?;
736                    let value = match which {
737                        1 => p.x,
738                        2 => p.y,
739                        _ => p.z,
740                    };
741                    Ok(value / scale)
742                })
743            }
744            _ => ogeom_bail!(
745                Construction,
746                "D{de}: offset curve type {kind} names no distance law"
747            ),
748        };
749        let mut points = Vec::with_capacity(parameters.len());
750        for (i, &t) in parameters.iter().enumerate() {
751            let p = basis.point_at(t, self.tol)?;
752            let tangent = basis.d1_at(t, self.tol)?;
753            let side = reference.cross(tangent);
754            let m = side.magnitude();
755            if m <= self.tol.angular() {
756                ogeom_bail!(
757                    Construction,
758                    "D{de}: the offset's reference runs along its base curve"
759                );
760            }
761            points.push(p + side * (distance(i, t)? * scale / m));
762        }
763        let fitted = ogeom_geom::fit::fit_points_at(
764            &parameters,
765            &points,
766            3,
767            self.tol.confusion() * 100.0,
768            self.tol,
769        )?;
770        if !fitted.met {
771            self.report.warnings.push(format!(
772                "D{de}: a varying offset curve fitted to {:.2e}",
773                fitted.error
774            ));
775        }
776        Ok((Curve::from(fitted.curve), range))
777    }
778
779    fn spline_curve(&mut self, de: i64, entity: &Entity) -> OgeomResult<(Curve, (f64, f64))> {
780        let n = usize::try_from(entity.at(3).int()).unwrap_or(0);
781        if n == 0 {
782            ogeom_bail!(Construction, "D{de}: a spline curve with no segments");
783        }
784        let scale = self.report.scale_mm;
785        // Break points T(1..=N+1), then 12 coefficients per segment; the
786        // polynomial argument runs over [0, h] within each span.
787        let t = |i: usize| entity.at(4 + i).real();
788        let base = 4 + n + 1;
789        let mut control: Vec<Point> = Vec::with_capacity(3 * n + 1);
790        let mut knots: Vec<f64> = vec![t(0); 4];
791        for seg in 0..n {
792            let h = t(seg + 1) - t(seg);
793            if h <= 0.0 {
794                ogeom_bail!(Construction, "D{de}: spline segment {seg} has no span");
795            }
796            let co = |k: usize| entity.at(base + 12 * seg + k).real();
797            let (ax, bx, cx, dx) = (co(0), co(1), co(2), co(3));
798            let (ay, by, cy, dy) = (co(4), co(5), co(6), co(7));
799            let (az, bz, cz, dz) = (co(8), co(9), co(10), co(11));
800            let at = |s: f64| {
801                Point::new(
802                    (ax + s * (bx + s * (cx + s * dx))) * scale,
803                    (ay + s * (by + s * (cy + s * dy))) * scale,
804                    (az + s * (bz + s * (cz + s * dz))) * scale,
805                )
806            };
807            // Bernstein form of a cubic on [0, h]: the endpoints, and one
808            // third of the end derivatives standing off them.
809            let p0 = at(0.0);
810            let p3 = at(h);
811            let d0 = Vector::new(bx, by, bz) * (h * scale / 3.0);
812            let d1 = Vector::new(
813                bx + 2.0 * cx * h + 3.0 * dx * h * h,
814                by + 2.0 * cy * h + 3.0 * dy * h * h,
815                bz + 2.0 * cz * h + 3.0 * dz * h * h,
816            ) * (h * scale / 3.0);
817            if seg == 0 {
818                control.push(p0);
819            }
820            control.push(p0 + d0);
821            control.push(p3 - d1);
822            control.push(p3);
823            if seg + 1 < n {
824                knots.extend([t(seg + 1); 3]);
825            }
826        }
827        knots.extend([t(n); 4]);
828        let curve = BSplineCurve::new(KnotVector::new(knots, 3)?, control, self.tol)?;
829        let range = ogeom_geom::Curve3d::domain(&curve);
830        Ok((Curve::from(curve), range))
831    }
832
833    /// Rational B-spline curve, the direct translation.
834    fn nurbs_curve(&mut self, de: i64, entity: &Entity) -> OgeomResult<(Curve, (f64, f64))> {
835        let k = usize::try_from(entity.at(0).int()).unwrap_or(0);
836        let degree = usize::try_from(entity.at(1).int()).unwrap_or(0);
837        if degree == 0 {
838            ogeom_bail!(Construction, "D{de}: a B-spline curve of degree zero");
839        }
840        let n_ctrl = k + 1;
841        let n_knots = n_ctrl + degree + 1;
842        let knots: Vec<f64> = (0..n_knots).map(|i| entity.at(6 + i).real()).collect();
843        let w_base = 6 + n_knots;
844        let p_base = w_base + n_ctrl;
845        let scale = self.report.scale_mm;
846        let mut control = Vec::with_capacity(n_ctrl);
847        for i in 0..n_ctrl {
848            let w = entity.at(w_base + i).real();
849            let p = Point::new(
850                entity.at(p_base + 3 * i).real() * scale,
851                entity.at(p_base + 3 * i + 1).real() * scale,
852                entity.at(p_base + 3 * i + 2).real() * scale,
853            );
854            control.push(Weighted::new(p, w, self.tol)?);
855        }
856        let curve = BSplineCurve::rational(KnotVector::new(knots, degree)?, control)?;
857        let v0 = entity.at(p_base + 3 * n_ctrl).real();
858        let v1 = entity.at(p_base + 3 * n_ctrl + 1).real();
859        let domain = ogeom_geom::Curve3d::domain(&curve);
860        let range = if v1 > v0 { (v0, v1) } else { domain };
861        Ok((Curve::from(curve), range))
862    }
863
864    /// A model-space surface.
865    fn surface(&mut self, de: i64) -> OgeomResult<SurfaceGeometry> {
866        let entity = self.entity(de)?;
867        let scale = self.report.scale_mm;
868        let surface: SurfaceGeometry = match entity.kind {
869            108 => {
870                // A x + B y + C z = D, unbounded; any face's trim bounds it.
871                let normal = Vector::new(
872                    entity.at(0).real(),
873                    entity.at(1).real(),
874                    entity.at(2).real(),
875                );
876                let d = entity.at(3).real();
877                let origin = Point::ORIGIN + normal * (d / normal.dot(normal)) * scale;
878                let plane = Plane::through(origin, Direction::new(normal, self.tol)?);
879                PlaneSurface::over(
880                    plane,
881                    (-SURFACE_EXTENT, SURFACE_EXTENT),
882                    (-SURFACE_EXTENT, SURFACE_EXTENT),
883                )?
884                .into()
885            }
886            190 => {
887                let point = self.location_entity(entity.at(0).int())?;
888                let normal = self.direction_entity(entity.at(1).int())?;
889                // A parameterized plane names the direction its `u` runs.
890                let plane = match self.reference_direction(entity, 2)? {
891                    Some(x) => Plane::new(Frame::new(point, normal, x, self.tol)?),
892                    None => Plane::through(point, normal),
893                };
894                PlaneSurface::over(
895                    plane,
896                    (-SURFACE_EXTENT, SURFACE_EXTENT),
897                    (-SURFACE_EXTENT, SURFACE_EXTENT),
898                )?
899                .into()
900            }
901            120 => {
902                // Axis line, generatrix, start and terminate angles.
903                let axis = {
904                    let (line, _) = self.curve(entity.at(0).int())?;
905                    let Curve::Line(l) = line else {
906                        ogeom_bail!(
907                            Construction,
908                            "D{de}: a surface of revolution's axis is not a line"
909                        );
910                    };
911                    l.axis()
912                };
913                let (curve, range) = self.curve(entity.at(1).int())?;
914                let mut curve = trimmed_to(curve, range, self.tol)?;
915                let sa = entity.at(2).real();
916                let ta = entity.at(3).real();
917                let sweep = if ta > sa {
918                    ta - sa
919                } else {
920                    core::f64::consts::TAU
921                };
922                if sa.abs() > self.tol.parametric() {
923                    curve = curve.transformed(&Transform::rotation(axis, sa), self.tol)?;
924                }
925                RevolutionSurface::new(curve, axis, sweep)?.into()
926            }
927            122 => {
928                // Directrix, plus the far end of a generator drawn through
929                // the directrix's start point.
930                let (curve, range) = self.curve(entity.at(0).int())?;
931                let start = curve.point_at(range.0, self.tol)?;
932                let far = self.point3(entity, 1);
933                let vec = far - start;
934                let curve = trimmed_to(curve, range, self.tol)?;
935                ExtrusionSurface::new(curve, Direction::new(vec, self.tol)?, vec.magnitude())?
936                    .into()
937            }
938            128 => self.nurbs_surface(de, entity)?,
939            114 => self.spline_surface(de, entity)?,
940            118 => self.ruled_surface(de, entity)?,
941            140 => {
942                // Normal, distance, base surface: displaced along the
943                // file's direction, which is the base's own normal or its
944                // opposite; the sign carries the difference.
945                let given = Direction::from_coords(
946                    entity.at(0).real(),
947                    entity.at(1).real(),
948                    entity.at(2).real(),
949                    self.tol,
950                )?;
951                let distance = entity.at(3).real() * scale;
952                let basis = self.surface(entity.at(4).int())?;
953                let ((ua, ub), (va, vb)) = ogeom_geom::Surface::domain(&basis);
954                let own = ogeom_geom::Surface::normal_at(
955                    &basis,
956                    f64::midpoint(ua, ub),
957                    f64::midpoint(va, vb),
958                    self.tol,
959                )?;
960                let signed = if own.vector().dot(given.vector()) < 0.0 {
961                    -distance
962                } else {
963                    distance
964                };
965                SurfaceGeometry::Offset(Box::new(OffsetSurface::new(basis, signed)?))
966            }
967            192 => {
968                let point = self.location_entity(entity.at(0).int())?;
969                let dir = self.direction_entity(entity.at(1).int())?;
970                let radius = entity.at(2).real() * scale;
971                let frame = self.framed(point, dir, entity, 3)?;
972                CylinderSurface::new(
973                    Cylinder::new(frame, radius, self.tol)?,
974                    (-SURFACE_EXTENT, SURFACE_EXTENT),
975                )?
976                .into()
977            }
978            194 => {
979                let point = self.location_entity(entity.at(0).int())?;
980                let dir = self.direction_entity(entity.at(1).int())?;
981                let radius = entity.at(2).real() * scale;
982                let half_angle = entity.at(3).real().to_radians();
983                let frame = self.framed(point, dir, entity, 4)?;
984                ConeSurface::new(
985                    Cone::new(frame, radius, half_angle, self.tol)?,
986                    (-SURFACE_EXTENT, SURFACE_EXTENT),
987                )?
988                .into()
989            }
990            196 => {
991                let centre = self.location_entity(entity.at(0).int())?;
992                let radius = entity.at(1).real() * scale;
993                // A parameterized sphere names its axis and where `u` starts.
994                if entity.at(2).int() != 0 {
995                    let axis = self.direction_entity(entity.at(2).int())?;
996                    let frame = self.framed(centre, axis, entity, 3)?;
997                    SphereSurface::new(Sphere::new(frame, radius, self.tol)?).into()
998                } else {
999                    SphereSurface::new(Sphere::centred(centre, radius, self.tol)?).into()
1000                }
1001            }
1002            198 => {
1003                let centre = self.location_entity(entity.at(0).int())?;
1004                let dir = self.direction_entity(entity.at(1).int())?;
1005                let major = entity.at(2).real() * scale;
1006                let minor = entity.at(3).real() * scale;
1007                let frame = self.framed(centre, dir, entity, 4)?;
1008                TorusSurface::new(Torus::new(frame, major, minor, self.tol)?).into()
1009            }
1010            kind => ogeom_bail!(
1011                Construction,
1012                "D{de}: surface entity type {kind}{} is not translated; see \
1013                 docs/PARITY.md, io.iges",
1014                super::entity_name(kind).map_or_else(String::new, |n| format!(" ({n})"))
1015            ),
1016        };
1017        let placement = self.placement(entity)?;
1018        if placement == Transform::IDENTITY {
1019            Ok(surface)
1020        } else {
1021            Ok(surface.transformed(&placement, self.tol)?)
1022        }
1023    }
1024
1025    /// Ruled surface (118): the straight lines between points of equal
1026    /// parameter on two curves, as a patch of degree one across. Both
1027    /// curves in their exact spline form over `[0, 1]`, raised to one
1028    /// degree and refined to one knot vector, the second walked backward
1029    /// where the file's direction flag says so. The file's form 0 joins
1030    /// points of equal *arc length* fraction; that is read as equal
1031    /// parameter, exact for the uniform-speed curves and a hair off for the
1032    /// rest.
1033    fn ruled_surface(&mut self, de: i64, entity: &Entity) -> OgeomResult<SurfaceGeometry> {
1034        let (first, first_range) = self.curve(entity.at(0).int())?;
1035        let (second, second_range) = self.curve(entity.at(1).int())?;
1036        let mut a = first.to_bspline_over(first_range, self.tol)?;
1037        let mut b = second.to_bspline_over(second_range, self.tol)?;
1038        if entity.at(2).int() == 1 {
1039            let Curve::BSpline(turned) =
1040                ogeom_geom::Reversible::reversed(&Curve::BSpline(b.clone()))
1041            else {
1042                ogeom_bail!(Construction, "D{de}: a reversed spline is not a spline");
1043            };
1044            b = turned;
1045        }
1046        while a.degree() < b.degree() {
1047            a = a.elevated(self.tol)?;
1048        }
1049        while b.degree() < a.degree() {
1050            b = b.elevated(self.tol)?;
1051        }
1052        let tol = self.tol;
1053        let unify = |target: &mut BSplineCurve, source: &BSplineCurve| -> OgeomResult<()> {
1054            let (lo, hi) = source.knots().domain();
1055            for (value, multiplicity) in source.knots().distinct() {
1056                if value <= lo + 1e-12 || value >= hi - 1e-12 {
1057                    continue;
1058                }
1059                let held = target.knots().multiplicity_of(value);
1060                if multiplicity > held {
1061                    *target = target.with_knot_inserted(value, multiplicity - held, tol)?;
1062                }
1063            }
1064            Ok(())
1065        };
1066        unify(&mut a, &b)?;
1067        unify(&mut b, &a)?;
1068        let nu = a.control_points().len();
1069        if b.control_points().len() != nu {
1070            ogeom_bail!(
1071                Construction,
1072                "D{de}: the ruled surface's curves refine to different nets"
1073            );
1074        }
1075        let mut points: Vec<Weighted<Point>> = Vec::with_capacity(nu * 2);
1076        for i in 0..nu {
1077            points.push(a.control_points()[i]);
1078            points.push(b.control_points()[i]);
1079        }
1080        let grid = ControlGrid::new(points, nu, 2)?;
1081        let across = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
1082        Ok(BSplineSurface::rational(a.knots().clone(), across, grid)?.into())
1083    }
1084
1085    /// Parametric spline surface (114): a grid of bicubic polynomial
1086    /// patches, each over its own span of break points, its sixteen
1087    /// coefficients per coordinate multiplying `s^m t^l` at index `m + 4l`
1088    /// with `s` and `t` measured from the patch's first break points. Each
1089    /// patch is converted to Bézier form exactly (its monomials scaled to
1090    /// the unit square, then to the Bernstein basis both ways) and the
1091    /// patches joined on shared rows, every break point a knot of
1092    /// multiplicity three: the spline curve's (112) construction, both ways.
1093    /// After each row of patches the file carries one arbitrary patch, and
1094    /// after the last row one arbitrary row; both are skipped.
1095    fn spline_surface(&mut self, de: i64, entity: &Entity) -> OgeomResult<SurfaceGeometry> {
1096        let m = usize::try_from(entity.at(2).int()).unwrap_or(0);
1097        let n = usize::try_from(entity.at(3).int()).unwrap_or(0);
1098        if m == 0 || n == 0 {
1099            ogeom_bail!(Construction, "D{de}: a spline surface with no patches");
1100        }
1101        let scale = self.report.scale_mm;
1102        let tu = |i: usize| entity.at(4 + i).real();
1103        let tv = |j: usize| entity.at(4 + m + 1 + j).real();
1104        let base = 4 + (m + 1) + (n + 1);
1105        let (rows, columns) = (3 * m + 1, 3 * n + 1);
1106        let mut net = vec![Point::ORIGIN; rows * columns];
1107        // Monomial coefficients on [0, 1] to Bernstein control values:
1108        // `p_r = Σ_{k ≤ r} C(r, k) / C(3, k) · b_k`.
1109        const TO_BERNSTEIN: [[f64; 4]; 4] = [
1110            [1.0, 0.0, 0.0, 0.0],
1111            [1.0, 1.0 / 3.0, 0.0, 0.0],
1112            [1.0, 2.0 / 3.0, 1.0 / 3.0, 0.0],
1113            [1.0, 1.0, 1.0, 1.0],
1114        ];
1115        for i in 0..m {
1116            let h = tu(i + 1) - tu(i);
1117            for j in 0..n {
1118                let k = tv(j + 1) - tv(j);
1119                if h <= 0.0 || k <= 0.0 {
1120                    ogeom_bail!(
1121                        Construction,
1122                        "D{de}: spline surface patch ({i}, {j}) has no span"
1123                    );
1124                }
1125                let at = base + (i * (n + 1) + j) * 48;
1126                let mut coords = [[[0.0_f64; 4]; 4]; 3];
1127                for (axis, block) in coords.iter_mut().enumerate() {
1128                    // b[mu][lv]: the coefficient of σ^mu τ^lv on the unit square.
1129                    let mut b = [[0.0_f64; 4]; 4];
1130                    for (mu, row) in b.iter_mut().enumerate() {
1131                        for (lv, value) in row.iter_mut().enumerate() {
1132                            let raw = entity.at(at + 16 * axis + mu + 4 * lv).real();
1133                            #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
1134                            let scaled = raw * h.powi(mu as i32) * k.powi(lv as i32);
1135                            *value = scaled;
1136                        }
1137                    }
1138                    // Bernstein in s, then in t.
1139                    let mut q = [[0.0_f64; 4]; 4];
1140                    for r in 0..4 {
1141                        for lv in 0..4 {
1142                            q[r][lv] = (0..4).map(|mu| TO_BERNSTEIN[r][mu] * b[mu][lv]).sum();
1143                        }
1144                    }
1145                    for r in 0..4 {
1146                        for c in 0..4 {
1147                            block[r][c] = (0..4).map(|lv| TO_BERNSTEIN[c][lv] * q[r][lv]).sum();
1148                        }
1149                    }
1150                }
1151                for r in 0..4 {
1152                    for c in 0..4 {
1153                        net[(3 * i + r) * columns + 3 * j + c] = Point::new(
1154                            coords[0][r][c] * scale,
1155                            coords[1][r][c] * scale,
1156                            coords[2][r][c] * scale,
1157                        );
1158                    }
1159                }
1160            }
1161        }
1162        let knots = |count: usize, at: &dyn Fn(usize) -> f64| -> Vec<f64> {
1163            let mut out = vec![at(0); 4];
1164            for i in 1..count {
1165                out.extend([at(i); 3]);
1166            }
1167            out.extend([at(count); 4]);
1168            out
1169        };
1170        let grid = ControlGrid::new(
1171            net.into_iter()
1172                .map(|p| Weighted::new(p, 1.0, self.tol))
1173                .collect::<OgeomResult<Vec<_>>>()?,
1174            rows,
1175            columns,
1176        )?;
1177        Ok(ogeom_geom::BSplineSurface::rational(
1178            KnotVector::new(knots(m, &tu), 3)?,
1179            KnotVector::new(knots(n, &tv), 3)?,
1180            grid,
1181        )?
1182        .into())
1183    }
1184
1185    fn nurbs_surface(&mut self, de: i64, entity: &Entity) -> OgeomResult<SurfaceGeometry> {
1186        let k1 = usize::try_from(entity.at(0).int()).unwrap_or(0);
1187        let k2 = usize::try_from(entity.at(1).int()).unwrap_or(0);
1188        let m1 = usize::try_from(entity.at(2).int()).unwrap_or(0);
1189        let m2 = usize::try_from(entity.at(3).int()).unwrap_or(0);
1190        if m1 == 0 || m2 == 0 {
1191            ogeom_bail!(Construction, "D{de}: a B-spline surface of degree zero");
1192        }
1193        let (nu, nv) = (k1 + 1, k2 + 1);
1194        let (nku, nkv) = (nu + m1 + 1, nv + m2 + 1);
1195        let base = 9;
1196        let u_knots: Vec<f64> = (0..nku).map(|i| entity.at(base + i).real()).collect();
1197        let v_knots: Vec<f64> = (0..nkv).map(|i| entity.at(base + nku + i).real()).collect();
1198        let w_base = base + nku + nkv;
1199        let p_base = w_base + nu * nv;
1200        let scale = self.report.scale_mm;
1201        // The file lists control points with the first (u) index varying
1202        // fastest; the grid stores row-major with u as the slow index, so
1203        // the read reorders.
1204        let raw = |u: usize, v: usize| -> OgeomResult<Weighted<Point>> {
1205            let i = v * nu + u;
1206            let w = entity.at(w_base + i).real();
1207            let p = Point::new(
1208                entity.at(p_base + 3 * i).real() * scale,
1209                entity.at(p_base + 3 * i + 1).real() * scale,
1210                entity.at(p_base + 3 * i + 2).real() * scale,
1211            );
1212            Weighted::new(p, w, self.tol)
1213        };
1214        let mut weighted = Vec::with_capacity(nu * nv);
1215        for u in 0..nu {
1216            for v in 0..nv {
1217                weighted.push(raw(u, v)?);
1218            }
1219        }
1220        let grid = ControlGrid::new(weighted, nu, nv)?;
1221        Ok(ogeom_geom::BSplineSurface::rational(
1222            KnotVector::new(u_knots, m1)?,
1223            KnotVector::new(v_knots, m2)?,
1224            grid,
1225        )?
1226        .into())
1227    }
1228
1229    /// A point entity (116) or bare coordinate triple carrier.
1230    fn location_entity(&mut self, de: i64) -> OgeomResult<Point> {
1231        let e = self.entity(de)?;
1232        if e.kind != 116 {
1233            ogeom_bail!(
1234                Construction,
1235                "D{de}: expected a point entity, found type {}",
1236                e.kind
1237            );
1238        }
1239        Ok(self.point3(e, 0))
1240    }
1241
1242    /// A direction entity (123) as a unit vector.
1243    /// The reference direction a parameterized analytic surface names at
1244    /// `field`, where it names one: where its angle or `u` starts.
1245    fn reference_direction(
1246        &mut self,
1247        entity: &Entity,
1248        field: usize,
1249    ) -> OgeomResult<Option<Direction>> {
1250        let de = entity.at(field).int();
1251        if de == 0 || !self.file.entities.contains_key(&de) {
1252            return Ok(None);
1253        }
1254        Ok(Some(self.direction_entity(de)?))
1255    }
1256
1257    /// A frame about `axis` at `origin`, its `x` the surface's reference
1258    /// direction where it names one (squared off the axis), else any.
1259    fn framed(
1260        &mut self,
1261        origin: Point,
1262        axis: Direction,
1263        entity: &Entity,
1264        field: usize,
1265    ) -> OgeomResult<Frame> {
1266        if let Some(reference) = self.reference_direction(entity, field)? {
1267            let r = reference.vector();
1268            let square = r - axis.vector() * r.dot(axis.vector());
1269            if let Ok(x) = Direction::new(square, self.tol) {
1270                return Frame::new(origin, axis, x, self.tol);
1271            }
1272        }
1273        frame_about(origin, axis, self.tol)
1274    }
1275
1276    fn direction_entity(&mut self, de: i64) -> OgeomResult<Direction> {
1277        let e = self.entity(de)?;
1278        if e.kind != 123 {
1279            ogeom_bail!(
1280                Construction,
1281                "D{de}: expected a direction entity, found type {}",
1282                e.kind
1283            );
1284        }
1285        Direction::from_coords(e.at(0).real(), e.at(1).real(), e.at(2).real(), self.tol)
1286    }
1287
1288    /// The boundary of a trimmed face as model-space curve segments: from a
1289    /// curve-on-surface (142), a boundary entity (141), or a bare curve,
1290    /// walking composite curves flat.
1291    fn boundary_segments(&mut self, de: i64) -> OgeomResult<Vec<(Curve, (f64, f64))>> {
1292        let entity = self.entity(de)?;
1293        match entity.kind {
1294            // Curve on surface: the model-space curve is the trim's truth;
1295            // the file's pcurve is advisory, because faces recompute exact
1296            // pcurves and say when they cannot.
1297            // Creation, surface, parameter-space curve, model-space curve.
1298            142 => {
1299                let c = entity.at(3).int();
1300                if c != 0 {
1301                    return self.curve_segments(c);
1302                }
1303                let (surface_de, b) = (entity.at(1).int(), entity.at(2).int());
1304                self.lifted_segments(de, surface_de, b)
1305            }
1306            141 => {
1307                let n = usize::try_from(entity.at(3).int()).unwrap_or(0);
1308                let mut out = Vec::new();
1309                let mut i = 4;
1310                for _ in 0..n {
1311                    let cptr = entity.at(i).int();
1312                    let k = usize::try_from(entity.at(i + 2).int()).unwrap_or(0);
1313                    i += 3 + k;
1314                    // The sense flag is advisory here too: the wire builder
1315                    // chains segments by their geometry.
1316                    out.extend(self.curve_segments(cptr)?);
1317                }
1318                Ok(out)
1319            }
1320            _ => self.curve_segments(de),
1321        }
1322    }
1323
1324    /// A trim given only in the surface's parameters: each parameter-space
1325    /// segment lifted through the surface into a model-space curve, fitted
1326    /// same-parameter with the composition to a hundredth of a micron. On a
1327    /// B-spline surface the file's parameters are the surface's own; an
1328    /// analytic surface's IGES parameterization is the file's convention,
1329    /// not this kernel's, and its lift is refused by name.
1330    fn lifted_segments(
1331        &mut self,
1332        de: i64,
1333        surface_de: i64,
1334        b: i64,
1335    ) -> OgeomResult<Vec<(Curve, (f64, f64))>> {
1336        let kind = self.entity(surface_de)?.kind;
1337        let surface = self.surface(surface_de)?;
1338        let scale = self.report.scale_mm;
1339        // The file's parameters into this kernel's chart. A B-spline's are
1340        // its own; the analytic surfaces' are the format's: lengths along
1341        // a plane's axes, a cylinder's and cone's angle in degrees with a
1342        // length along the axis (a cone's along its slant), a sphere's
1343        // two angles in degrees, and a torus's pair turned: its first
1344        // round the tube, its second (measured back from a full turn)
1345        // round the axis.
1346        let half_angle = match &surface {
1347            SurfaceGeometry::Cone(c) => c.cone().half_angle(),
1348            _ => 0.0,
1349        };
1350        // A tabulated cylinder's parameters are both fractions: of its
1351        // directrix's run and of the generator. A surface of revolution's
1352        // are its generatrix's own parameter and the angle turned from the
1353        // start angle; a line's own runs over [0, 1], a spline's is its own.
1354        let swept = match (kind, &surface) {
1355            (122, SurfaceGeometry::Extrusion(e)) => {
1356                let (lo, hi) = ogeom_geom::Curve3d::domain(e.curve());
1357                let extent = ogeom_geom::Surface::domain(&surface).1;
1358                Some(((lo, hi - lo), (extent.0, extent.1 - extent.0), false))
1359            }
1360            (120, SurfaceGeometry::Revolution(r)) => {
1361                let generatrix = self.entity(surface_de)?.at(1).int();
1362                let (lo, hi) = ogeom_geom::Curve3d::domain(r.curve());
1363                match self.entity(generatrix)?.kind {
1364                    110 => Some(((lo, hi - lo), (0.0, 1.0), true)),
1365                    126 => Some(((0.0, 1.0), (0.0, 1.0), true)),
1366                    _ => None,
1367                }
1368            }
1369            _ => None,
1370        };
1371        let start_angle = if kind == 120 {
1372            self.entity(surface_de)?.at(2).real()
1373        } else {
1374            0.0
1375        };
1376        let map = |p: ogeom_math::Point2| -> Option<ogeom_math::Point2> {
1377            use ogeom_math::Point2 as P;
1378            // The parameter-space curve was read as a model-space one and
1379            // carries the unit scale on both coordinates.
1380            let (u, v) = (p.x / scale, p.y / scale);
1381            match kind {
1382                128 => Some(P::new(u, v)),
1383                190 => Some(P::new(u * scale, v * scale)),
1384                192 => Some(P::new(u.to_radians(), v * scale)),
1385                194 => Some(P::new(u.to_radians(), v * scale * half_angle.cos())),
1386                196 => Some(P::new(u.to_radians(), v.to_radians())),
1387                198 => Some(P::new((360.0 - v).to_radians(), u.to_radians())),
1388                122 => swept.map(|((u0, du), (v0, dv), _)| P::new(u0 + du * u, v0 + dv * v)),
1389                // The generatrix's parameter in `u`, the angle in `v`; the
1390                // surface was built with the start angle turned in.
1391                120 => swept.map(|((u0, du), _, _)| P::new(v - start_angle, u0 + du * u)),
1392                _ => None,
1393            }
1394        };
1395        if map(ogeom_math::Point2::new(0.0, 0.0)).is_none() {
1396            ogeom_bail!(
1397                Construction,
1398                "D{de}: a curve-on-surface carries no model-space curve, and \
1399                 its surface's parameters are the file's convention for a \
1400                 kind this reader does not translate; see docs/PARITY.md, io.iges"
1401            );
1402        }
1403        let mut out = Vec::new();
1404        for (curve, range) in self.curve_segments(b)? {
1405            // The parameter-space curve read as a model-space one carries
1406            // the unit scale; the surface's parameters do not.
1407            const SAMPLES: usize = 200;
1408            let mut parameters = Vec::with_capacity(SAMPLES + 1);
1409            let mut points = Vec::with_capacity(SAMPLES + 1);
1410            for i in 0..=SAMPLES {
1411                #[allow(clippy::cast_precision_loss)]
1412                let t = range.0 + (range.1 - range.0) * (i as f64) / SAMPLES as f64;
1413                let p = curve.point_at(t, self.tol)?;
1414                parameters.push(t);
1415                let Some(q) = map(ogeom_math::Point2::new(p.x, p.y)) else {
1416                    ogeom_bail!(Construction, "D{de}: a trim's parameters did not translate");
1417                };
1418                points.push(q);
1419            }
1420            let chart =
1421                ogeom_geom::fit::fit_points_2d_at(&parameters, &points, 3, 1e-10, self.tol)?;
1422            let lifted = Curve::OnSurface(Box::new(ogeom_geom::CurveOnSurface::new(
1423                chart.curve.into(),
1424                surface.clone(),
1425            )));
1426            let fitted =
1427                lifted.fitted_bspline_over(range, self.tol.confusion() * 100.0, self.tol)?;
1428            if !fitted.met {
1429                self.report.warnings.push(format!(
1430                    "D{de}: a parameter-space trim lifted to within {:.2e}",
1431                    fitted.error
1432                ));
1433            }
1434            let domain = ogeom_geom::Curve3d::domain(&fitted.curve);
1435            out.push((Curve::from(fitted.curve), domain));
1436        }
1437        Ok(out)
1438    }
1439
1440    fn curve_segments(&mut self, de: i64) -> OgeomResult<Vec<(Curve, (f64, f64))>> {
1441        let entity = self.entity(de)?;
1442        if entity.kind == 102 {
1443            let n = usize::try_from(entity.at(0).int()).unwrap_or(0);
1444            let mut out = Vec::new();
1445            for i in 0..n {
1446                out.extend(self.curve_segments(entity.at(1 + i).int())?);
1447            }
1448            return Ok(out);
1449        }
1450        Ok(vec![self.curve(de)?])
1451    }
1452
1453    /// A trimmed (144) or bounded (143) surface as a face.
1454    fn face(&mut self, de: i64) -> OgeomResult<Shape> {
1455        let entity = self.entity(de)?;
1456        let (surface_de, boundaries): (i64, Vec<i64>) = match entity.kind {
1457            144 => {
1458                let s = entity.at(0).int();
1459                let outer_given = entity.at(1).int() == 1;
1460                let n_inner = usize::try_from(entity.at(2).int()).unwrap_or(0);
1461                let mut bs = Vec::new();
1462                if outer_given && entity.at(3).int() != 0 {
1463                    bs.push(entity.at(3).int());
1464                }
1465                for i in 0..n_inner {
1466                    bs.push(entity.at(4 + i).int());
1467                }
1468                (s, bs)
1469            }
1470            143 => {
1471                let s = entity.at(1).int();
1472                let n = usize::try_from(entity.at(2).int()).unwrap_or(0);
1473                (s, (0..n).map(|i| entity.at(3 + i).int()).collect())
1474            }
1475            kind => ogeom_bail!(Construction, "D{de}: type {kind} is not a trimmed surface"),
1476        };
1477        let surface = self.surface(surface_de)?;
1478        if boundaries.is_empty() {
1479            // The surface's own natural boundary: a closed or bounded
1480            // surface can stand alone as a face.
1481            return Ok(ogeom_algo::make_natural_face(&mut self.model, surface)?.shape);
1482        }
1483        let mut wires = Vec::new();
1484        for boundary in boundaries {
1485            let segments = self.boundary_segments(boundary)?;
1486            wires.push(self.wire_edges(de, segments)?);
1487        }
1488        self.assemble_face(surface, wires)
1489    }
1490
1491    /// Boundary segments into a closed chain of edges, head to tail.
1492    ///
1493    /// Surface files are loose about sense (a boundary's segments arrive in
1494    /// order but each may run either way), so the chain is stitched by
1495    /// geometry: each segment joins whichever of its ends sits at the chain's
1496    /// current head, and the last vertex is the first, which is what closes
1497    /// the wire.
1498    fn wire_edges(
1499        &mut self,
1500        face_de: i64,
1501        segments: Vec<(Curve, (f64, f64))>,
1502    ) -> OgeomResult<Vec<Shape>> {
1503        if segments.is_empty() {
1504            ogeom_bail!(Construction, "D{face_de}: a boundary with no curves");
1505        }
1506        let ends: Vec<(Point, Point)> = segments
1507            .iter()
1508            .map(|(c, r)| Ok((c.point_at(r.0, self.tol)?, c.point_at(r.1, self.tol)?)))
1509            .collect::<OgeomResult<_>>()?;
1510        let n = segments.len();
1511        let weld = self.tol.confusion() * 100.0;
1512
1513        // One closed segment closes on a single vertex.
1514        if n == 1 {
1515            let (curve, range) = segments
1516                .into_iter()
1517                .next()
1518                .unwrap_or_else(|| unreachable!());
1519            let (s, e) = ends[0];
1520            if s.distance(e) > weld {
1521                ogeom_bail!(
1522                    Construction,
1523                    "D{face_de}: a one-curve boundary whose ends sit {:.2e} apart",
1524                    s.distance(e)
1525                );
1526            }
1527            let v = make_vertex(&mut self.model, s).shape;
1528            let edge = make_edge_between(&mut self.model, curve, range, &v, &v, self.tol)?.shape;
1529            return Ok(vec![edge]);
1530        }
1531
1532        let head = make_vertex(&mut self.model, ends[0].0).shape;
1533        let mut at = ends[0].0;
1534        let mut at_vertex = head.clone();
1535        let mut edges = Vec::with_capacity(n);
1536        for (i, ((curve, range), (s, e))) in segments.into_iter().zip(ends).enumerate() {
1537            let forward = at.distance(s) <= at.distance(e);
1538            let (this_end, this_point) = if forward { (e, e) } else { (s, s) };
1539            let gap = at.distance(if forward { s } else { e });
1540            if gap > weld {
1541                ogeom_bail!(
1542                    Construction,
1543                    "D{face_de}: boundary segment {i} starts {gap:.2e} from \
1544                     where the previous one ended"
1545                );
1546            }
1547            let last = i + 1 == n;
1548            let next_vertex = if last {
1549                head.clone()
1550            } else {
1551                make_vertex(&mut self.model, this_end).shape
1552            };
1553            // The edge is built along its curve's own direction; a segment
1554            // running against the chain is used reversed, exactly as a
1555            // hand-built prism's rim edges are.
1556            let edge = if forward {
1557                make_edge_between(
1558                    &mut self.model,
1559                    curve,
1560                    range,
1561                    &at_vertex,
1562                    &next_vertex,
1563                    self.tol,
1564                )?
1565                .shape
1566            } else {
1567                make_edge_between(
1568                    &mut self.model,
1569                    curve,
1570                    range,
1571                    &next_vertex,
1572                    &at_vertex,
1573                    self.tol,
1574                )?
1575                .shape
1576                .reversed()
1577            };
1578            edges.push(edge);
1579            at = this_point;
1580            at_vertex = next_vertex;
1581        }
1582        Ok(edges)
1583    }
1584
1585    /// A face from a surface and wires of already-built edges: the exact
1586    /// pcurve where the pair has a closed form, the fitted one where it does
1587    /// not, and both sides of the chart for an edge the wire uses twice,
1588    /// which is what a seam is.
1589    fn assemble_face(
1590        &mut self,
1591        surface: SurfaceGeometry,
1592        wires: Vec<Vec<Shape>>,
1593    ) -> OgeomResult<Shape> {
1594        // A single wire that is one edge used twice on a periodic surface is
1595        // a seam and nothing else, and a boundary that is nothing but the
1596        // seam encloses the whole chart: the face is the surface, and the
1597        // natural face carries its own degenerate boundary (a sphere's
1598        // poles), which the file had no edges for.
1599        if let [edges] = wires.as_slice()
1600            && let [a, b] = edges.as_slice()
1601            && a.node() == b.node()
1602        {
1603            use ogeom_geom::Surface as _;
1604            if surface.is_periodic_u() || surface.is_periodic_v() {
1605                return Ok(ogeom_algo::make_natural_face(&mut self.model, surface)?.shape);
1606            }
1607        }
1608        let surface_id = self.model.geometry_mut().add_surface(surface.clone());
1609        let mut wire_shapes = Vec::with_capacity(wires.len());
1610        for edges in &wires {
1611            wire_shapes.push(ogeom_algo::make_wire(&mut self.model, edges, self.tol)?.shape);
1612        }
1613        let face =
1614            ogeom_algo::make_face_on(&mut self.model, surface_id, &wire_shapes, self.tol)?.shape;
1615
1616        for edges in &wires {
1617            self.chart_wire(edges, &surface, surface_id)?;
1618        }
1619        Ok(face)
1620    }
1621
1622    /// One wire's pcurves, chained around the face's chart.
1623    ///
1624    /// Each edge's image is computed on its own (the exact projection where
1625    /// the pair has a closed form, the fitted one where it does not), and a
1626    /// periodic chart then has a branch to choose. Read one edge at a time
1627    /// the choice is arbitrary, and the wire comes apart: a bore's wall
1628    /// arrives with one rim written over `[−π, π]` and the other over
1629    /// `[−π/2, 3π/2]`, both the right circle and neither meeting the seam
1630    /// the wire closes on. So each image after the first is shifted by whole
1631    /// periods until its start meets where the last one ended, which is the
1632    /// rule `make_face_with_pcurves` follows for shapes this kernel builds
1633    /// itself.
1634    ///
1635    /// A seam falls out of the same walk. The wire uses it twice, up one
1636    /// column of the chart and down the other, and those columns *are* one
1637    /// image a period apart, so the chaining produces both, and the use
1638    /// that runs forward is the forward side.
1639    fn chart_wire(
1640        &mut self,
1641        edges: &[Shape],
1642        surface: &SurfaceGeometry,
1643        surface_id: ogeom_topo::SurfaceId,
1644    ) -> OgeomResult<()> {
1645        use ogeom_geom::Curve2d as _;
1646        let mut counts: HashMap<ogeom_topo::TShapeId, usize> = HashMap::new();
1647        for edge in edges {
1648            *counts.entry(edge.node()).or_default() += 1;
1649        }
1650        // One image per edge, however many times the wire walks it.
1651        let mut images: HashMap<ogeom_topo::TShapeId, (ogeom_geom::PlanarCurve, (f64, f64))> =
1652            HashMap::new();
1653        // The sides each walk of it left, by the direction that walk ran.
1654        let mut sides: HashMap<
1655            ogeom_topo::TShapeId,
1656            (
1657                Option<ogeom_geom::PlanarCurve>,
1658                Option<ogeom_geom::PlanarCurve>,
1659            ),
1660        > = HashMap::new();
1661        let mut order: Vec<ogeom_topo::TShapeId> = Vec::new();
1662        let mut previous: Option<ogeom_math::Point2> = None;
1663        for edge in edges {
1664            let (curve, range) = {
1665                let Some(data) = self.model.node(edge).and_then(|n| n.data().as_edge()) else {
1666                    continue;
1667                };
1668                let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d()
1669                else {
1670                    continue;
1671                };
1672                let Some(geometry) = self.model.geometry().curve(*curve) else {
1673                    continue;
1674                };
1675                (geometry.clone(), *range)
1676            };
1677            if let std::collections::hash_map::Entry::Vacant(slot) = images.entry(edge.node()) {
1678                let Some(image) = self.image_of(edge, &curve, range, surface)? else {
1679                    continue;
1680                };
1681                slot.insert((image, range));
1682                order.push(edge.node());
1683            }
1684            let Some((image, range)) = images.get(&edge.node()).cloned() else {
1685                continue;
1686            };
1687            let backwards = edge.orientation() == ogeom_topo::Orientation::Reversed;
1688            let (start, end) = if backwards {
1689                (range.1, range.0)
1690            } else {
1691                (range.0, range.1)
1692            };
1693            let image =
1694                crate::pcurves::shifted_to_meet(&image, start, previous, surface, self.tol)?;
1695            previous = Some(image.point_at(end, self.tol)?);
1696            let walked = sides.entry(edge.node()).or_default();
1697            if backwards {
1698                walked.1 = Some(image);
1699            } else {
1700                walked.0 = Some(image);
1701            }
1702        }
1703        for node in order {
1704            let Some((image, range)) = images.get(&node).cloned() else {
1705                continue;
1706            };
1707            let Some(edge) = edges.iter().find(|e| e.node() == node) else {
1708                continue;
1709            };
1710            let walked = sides.remove(&node).unwrap_or((None, None));
1711            let seam = counts.get(&node).copied().unwrap_or(0) > 1;
1712            let columns = match &walked {
1713                (Some(forward), Some(reversed)) => {
1714                    let at = forward.point_at(range.0, self.tol)?;
1715                    let other = reversed.point_at(range.0, self.tol)?;
1716                    (at.distance(other) > self.tol.confusion()).then_some((forward, reversed))
1717                }
1718                _ => None,
1719            };
1720            match (seam, columns) {
1721                (true, Some((forward, reversed))) => {
1722                    ogeom_algo::attach_seam(
1723                        &mut self.model,
1724                        edge,
1725                        forward.clone(),
1726                        reversed.clone(),
1727                        surface_id,
1728                        ogeom_topo::Location::identity(),
1729                        range,
1730                    )?;
1731                }
1732                // The walk left the seam's two uses in one place: the chart
1733                // closes without being periodic (a skinned wall's is such a
1734                // chart, clamped and closed), and there is no period to
1735                // shift by. The other column goes a chart's width over,
1736                // toward the middle, which is where it went before there
1737                // was a walk to ask.
1738                (true, None) => {
1739                    let other = crate::pcurves::seam_other_side(&image, range, surface, self.tol)?;
1740                    ogeom_algo::attach_seam(
1741                        &mut self.model,
1742                        edge,
1743                        image,
1744                        other,
1745                        surface_id,
1746                        ogeom_topo::Location::identity(),
1747                        range,
1748                    )?;
1749                }
1750                (false, _) => {
1751                    let (forward, reversed) = walked;
1752                    ogeom_algo::attach_pcurve(
1753                        &mut self.model,
1754                        edge,
1755                        forward.or(reversed).unwrap_or(image),
1756                        surface_id,
1757                        ogeom_topo::Location::identity(),
1758                        range,
1759                    )?;
1760                }
1761            }
1762        }
1763        Ok(())
1764    }
1765
1766    /// One edge's image on one surface, exact where the pair has a closed
1767    /// form and fitted where it does not: the same policy the STEP reader
1768    /// applies, through the shared machinery. Where the wire puts it on a
1769    /// periodic chart is [`Self::chart_wire`]'s business.
1770    fn image_of(
1771        &mut self,
1772        edge: &Shape,
1773        curve: &Curve,
1774        range: (f64, f64),
1775        surface: &SurfaceGeometry,
1776    ) -> OgeomResult<Option<ogeom_geom::PlanarCurve>> {
1777        use ogeom_geom::PlanarCurve;
1778        let widen = |p: PlanarCurve| -> PlanarCurve {
1779            if let PlanarCurve::Line(l) = &p {
1780                use ogeom_geom::Curve2d as _;
1781                let (lo, hi) = (l.domain().0.min(range.0), l.domain().1.max(range.1));
1782                if let Ok(wider) = ogeom_geom::Line2d::over(l.axis(), lo, hi) {
1783                    return wider.into();
1784                }
1785            }
1786            p
1787        };
1788        let found = match ogeom_intersect::exact_pcurve_over(curve, range, surface, self.tol)
1789            .map(widen)
1790        {
1791            Some(exact) => exact,
1792            None => match crate::pcurves::fit_projected_pcurve(curve, range, surface, self.tol) {
1793                Ok((fitted, error, met, worst_off, slop)) => {
1794                    if let Some(w) = slop {
1795                        self.report.warnings.push(w);
1796                    }
1797                    if !met {
1798                        self.report.warnings.push(format!(
1799                            "a projected pcurve fit stopped at {error:.2e}; \
1800                             the face's mesh may sit that far off along this edge"
1801                        ));
1802                    }
1803                    if worst_off > self.tol.confusion()
1804                        && let Some(node) = self.model.node_mut(edge)
1805                        && let ogeom_topo::NodeData::Edge(data) = node.data_mut()
1806                    {
1807                        data.tolerance = data.tolerance.widen_to(worst_off + self.tol.confusion());
1808                    }
1809                    fitted
1810                }
1811                Err(e) => {
1812                    self.report.warnings.push(format!(
1813                        "no pcurve for an edge on this surface ({e}); the \
1814                         face may not triangulate"
1815                    ));
1816                    return Ok(None);
1817                }
1818            },
1819        };
1820        Ok(Some(found))
1821    }
1822
1823    /// A manifold solid B-rep object: shell of faces of loops of edges.
1824    fn manifold_solid(&mut self, de: i64) -> OgeomResult<Shape> {
1825        let entity = self.entity(de)?;
1826        let shell = self.shell(entity.at(0).int())?;
1827        let n_voids = usize::try_from(entity.at(2).int()).unwrap_or(0);
1828        let mut shells = vec![shell];
1829        for i in 0..n_voids {
1830            shells.push(self.shell(entity.at(3 + 2 * i).int())?);
1831        }
1832        let solid = make_solid(&mut self.model, &shells)?.shape;
1833        // A fitted trim widens its edge to the offset it measured; the
1834        // edge's vertices come along, and the solid keeps the containment
1835        // rule the checker holds it to.
1836        ogeom_algo::restore_containment(&mut self.model, &solid)?;
1837        Ok(solid)
1838    }
1839
1840    fn shell(&mut self, de: i64) -> OgeomResult<Shape> {
1841        let entity = self.entity(de)?;
1842        if entity.kind != 514 {
1843            ogeom_bail!(
1844                Construction,
1845                "D{de}: expected a shell, found type {}",
1846                entity.kind
1847            );
1848        }
1849        let n = usize::try_from(entity.at(0).int()).unwrap_or(0);
1850        let mut faces = Vec::with_capacity(n);
1851        for i in 0..n {
1852            let face_de = entity.at(1 + 2 * i).int();
1853            let same_sense = entity.at(2 + 2 * i).int() != 0;
1854            let face = self.brep_face(face_de)?;
1855            faces.push(if same_sense { face } else { face.reversed() });
1856        }
1857        Ok(ogeom_algo::make_shell(&mut self.model, &faces)?.shape)
1858    }
1859
1860    fn brep_face(&mut self, de: i64) -> OgeomResult<Shape> {
1861        let entity = self.entity(de)?;
1862        if entity.kind != 510 {
1863            ogeom_bail!(
1864                Construction,
1865                "D{de}: expected a face, found type {}",
1866                entity.kind
1867            );
1868        }
1869        let surface = self.surface(entity.at(0).int())?;
1870        let n_loops = usize::try_from(entity.at(1).int()).unwrap_or(0);
1871        // Parameter 2 is the outer-loop flag; the loop pointers follow.
1872        let mut wires = Vec::with_capacity(n_loops);
1873        for i in 0..n_loops {
1874            wires.push(self.loop_edges(entity.at(3 + i).int())?);
1875        }
1876        self.assemble_face(surface, wires)
1877    }
1878
1879    fn loop_edges(&mut self, de: i64) -> OgeomResult<Vec<Shape>> {
1880        let entity = self.entity(de)?;
1881        if entity.kind != 508 {
1882            ogeom_bail!(
1883                Construction,
1884                "D{de}: expected a loop, found type {}",
1885                entity.kind
1886            );
1887        }
1888        let n = usize::try_from(entity.at(0).int()).unwrap_or(0);
1889        let mut edges = Vec::with_capacity(n);
1890        let mut i = 1;
1891        for _ in 0..n {
1892            let is_vertex = entity.at(i).int() == 1;
1893            let list_de = entity.at(i + 1).int();
1894            let index = entity.at(i + 2).int();
1895            let orientation = entity.at(i + 3).int();
1896            let k = usize::try_from(entity.at(i + 4).int()).unwrap_or(0);
1897            i += 5 + 2 * k;
1898            if is_vertex {
1899                // A vertex entry marks a degenerate use; the face builder
1900                // rebuilds chart degeneracies from the surface itself.
1901                self.report.warnings.push(format!(
1902                    "D{de}: a loop lists a vertex entry, which this reader skips"
1903                ));
1904                continue;
1905            }
1906            let (edge, _, _) = self.list_edge(list_de, index)?;
1907            edges.push(if orientation != 0 {
1908                edge
1909            } else {
1910                edge.reversed()
1911            });
1912        }
1913        Ok(edges)
1914    }
1915
1916    /// Edge `index` (1-based) of an edge list (504), built once and shared.
1917    fn list_edge(&mut self, list_de: i64, index: i64) -> OgeomResult<BuiltEdge> {
1918        let key = (list_de, index);
1919        if let Some(found) = self.edges.get(&key) {
1920            return Ok(found.clone());
1921        }
1922        let entity = self.entity(list_de)?;
1923        if entity.kind != 504 {
1924            ogeom_bail!(
1925                Construction,
1926                "D{list_de}: expected an edge list, found type {}",
1927                entity.kind
1928            );
1929        }
1930        let i = usize::try_from(index - 1).map_err(|_| {
1931            ogeom_core::ogeom_err!(Construction, "D{list_de}: edge index {index} out of range")
1932        })?;
1933        let base = 1 + 5 * i;
1934        let curve_de = entity.at(base).int();
1935        let (sv_list, sv_index) = (entity.at(base + 1).int(), entity.at(base + 2).int());
1936        let (tv_list, tv_index) = (entity.at(base + 3).int(), entity.at(base + 4).int());
1937        let (curve, mut range) = self.curve(curve_de)?;
1938        let vs = self.list_vertex(sv_list, sv_index)?;
1939        let ve = self.list_vertex(tv_list, tv_index)?;
1940        let (ps, pe) = (self.point_of(&vs), self.point_of(&ve));
1941        // Re-derive the range from the vertices on this kernel's own
1942        // parameterization, exactly as the STEP reader does and for the same
1943        // reason: the file's parameterization is its own business.
1944        if let (Some(a), Some(b)) = (parameter_on(&curve, ps), parameter_on(&curve, pe)) {
1945            let period = if curve.is_periodic() {
1946                let (lo, hi) = curve.domain();
1947                hi - lo
1948            } else {
1949                0.0
1950            };
1951            range = if ps.distance(pe) < self.tol.confusion() && period > 0.0 {
1952                (a, a + period)
1953            } else if period > 0.0 && b <= a + self.tol.parametric() {
1954                (a, b + period)
1955            } else {
1956                (a, b)
1957            };
1958            // A vertex a few nanometres past a bounded curve's end projects
1959            // past it: the range is held to the curve's own domain, and the
1960            // vertex widens below to cover the rest of the miss.
1961            if period == 0.0 {
1962                let (lo, hi) = curve.domain();
1963                range = (range.0.clamp(lo, hi), range.1.clamp(lo, hi));
1964            }
1965        }
1966        // IGES states no tolerances, so a vertex is built at the confusion
1967        // tolerance and a curve end that misses it by rounding (a writer's
1968        // last digit, a few tenths of a nanometre) would refuse the whole
1969        // solid. As the STEP reader does, the vertex's tolerance grows to
1970        // state the miss, up to the millimetre past which a boundary is not
1971        // this curve's at all; beyond that the edge still refuses by name.
1972        let cap = self.tol.confusion() * 1e7;
1973        for (vertex, t) in [(&vs, range.0), (&ve, range.1)] {
1974            let (Ok(end), Some(stated)) = (
1975                curve.point_at(t, self.tol),
1976                self.model
1977                    .node(vertex)
1978                    .and_then(|n| n.data().as_vertex())
1979                    .map(|d| (d.point, d.tolerance)),
1980            ) else {
1981                continue;
1982            };
1983            let gap = end.distance(stated.0);
1984            if gap > stated.1.get() && gap <= cap {
1985                self.model.widen(
1986                    vertex,
1987                    ogeom_core::Tolerance::new(gap + self.tol.confusion())?,
1988                )?;
1989                self.vertex_misses.0 += 1;
1990                self.vertex_misses.1 = self.vertex_misses.1.max(gap);
1991            }
1992        }
1993        let edge =
1994            make_edge_between(&mut self.model, curve.clone(), range, &vs, &ve, self.tol)?.shape;
1995        let built = (edge, curve, range);
1996        self.edges.insert(key, built.clone());
1997        Ok(built)
1998    }
1999
2000    /// Vertex `index` (1-based) of a vertex list (502), built once and
2001    /// shared; sharing is what lets a closed shell close.
2002    fn list_vertex(&mut self, list_de: i64, index: i64) -> OgeomResult<Shape> {
2003        let key = (list_de, index);
2004        if let Some(found) = self.vertices.get(&key) {
2005            return Ok(found.clone());
2006        }
2007        let entity = self.entity(list_de)?;
2008        if entity.kind != 502 {
2009            ogeom_bail!(
2010                Construction,
2011                "D{list_de}: expected a vertex list, found type {}",
2012                entity.kind
2013            );
2014        }
2015        let i = usize::try_from(index - 1).map_err(|_| {
2016            ogeom_core::ogeom_err!(
2017                Construction,
2018                "D{list_de}: vertex index {index} out of range"
2019            )
2020        })?;
2021        let point = self.point3(entity, 1 + 3 * i);
2022        let vertex = make_vertex(&mut self.model, point).shape;
2023        self.vertices.insert(key, vertex.clone());
2024        Ok(vertex)
2025    }
2026
2027    fn point_of(&self, vertex: &Shape) -> Point {
2028        self.model
2029            .node(vertex)
2030            .and_then(|n| n.data().as_vertex())
2031            .map_or(Point::ORIGIN, |d| d.point)
2032    }
2033
2034    /// The document: parts named from entity labels, colours from the fixed
2035    /// palette and from 314 entities the directory colour fields point at.
2036    /// A constructive solid entity's solids: one for a primitive, a boolean
2037    /// tree or an instance, one per item for an assembly.
2038    fn csg_solids(&mut self, de: i64) -> OgeomResult<Vec<Shape>> {
2039        let entity = self.entity(de)?;
2040        if entity.kind != 184 {
2041            return Ok(vec![self.csg(de)?]);
2042        }
2043        // Solid assembly: items, then a placement matrix for each.
2044        let n = usize::try_from(entity.at(0).int()).unwrap_or(0);
2045        let mut out = Vec::with_capacity(n);
2046        for i in 0..n {
2047            let item = entity.at(1 + i).int();
2048            let matrix = entity.at(1 + n + i).int();
2049            let mut solid = self.csg(item)?;
2050            if matrix != 0 {
2051                let placement = {
2052                    let holder = Entity {
2053                        kind: 0,
2054                        form: 0,
2055                        transform: matrix,
2056                        colour: 0,
2057                        level: 0,
2058                        status: 0,
2059                        label: String::new(),
2060                        params: Vec::new(),
2061                    };
2062                    self.placement(&holder)?
2063                };
2064                let location = ogeom_topo::Location::of(self.model.add_datum(placement));
2065                solid = solid.moved(&location);
2066            }
2067            out.push(solid);
2068        }
2069        Ok(out)
2070    }
2071
2072    /// One constructive solid: a primitive (150–168), a boolean tree (180),
2073    /// a solid instance (430) or a manifold solid (186), under the entity's
2074    /// own placement.
2075    #[allow(clippy::too_many_lines, reason = "one case per primitive")]
2076    fn csg(&mut self, de: i64) -> OgeomResult<Shape> {
2077        let entity = self.entity(de)?;
2078        let s = self.report.scale_mm;
2079        let tol = self.tol;
2080        let r = |i: usize| entity.at(i).real();
2081        let length = |i: usize| entity.at(i).real() * s;
2082        let point = |i: usize| Point::new(r(i) * s, r(i + 1) * s, r(i + 2) * s);
2083        // An axis the file leaves at its default is the default axis.
2084        let direction = |i: usize, default: Vector| -> OgeomResult<Direction> {
2085            let v = Vector::new(r(i), r(i + 1), r(i + 2));
2086            Direction::new(if v.magnitude() > 0.0 { v } else { default }, tol)
2087        };
2088        let shape = match entity.kind {
2089            150 => {
2090                let frame = Frame::new(
2091                    point(3),
2092                    direction(9, Vector::Z)?,
2093                    direction(6, Vector::X)?,
2094                    tol,
2095                )?;
2096                ogeom_algo::make_box(
2097                    &mut self.model,
2098                    frame,
2099                    (length(0), length(1), length(2)),
2100                    tol,
2101                )?
2102                .shape
2103            }
2104            152 => {
2105                // The file's wedge narrows along its local y, to `LTX` along
2106                // x at y = LY; this vocabulary's narrows along its z. Its
2107                // frame is the file's turned: z along the file's y, x along
2108                // the file's z, so its y runs along the file's x.
2109                let x_file = direction(7, Vector::X)?;
2110                let z_file = direction(10, Vector::Z)?;
2111                let y_file = Direction::new(z_file.vector().cross(x_file.vector()), tol)?;
2112                let frame = Frame::new(point(4), y_file, z_file, tol)?;
2113                ogeom_algo::make_wedge(
2114                    &mut self.model,
2115                    frame,
2116                    (length(2), length(0), length(1)),
2117                    (length(2), length(3)),
2118                    tol,
2119                )?
2120                .shape
2121            }
2122            154 => {
2123                let frame = frame_about(point(2), direction(5, Vector::Z)?, tol)?;
2124                ogeom_algo::make_cylinder(&mut self.model, frame, length(1), length(0), tol)?.shape
2125            }
2126            156 => {
2127                let frame = frame_about(point(3), direction(6, Vector::Z)?, tol)?;
2128                ogeom_algo::make_cone(&mut self.model, frame, length(1), length(2), length(0), tol)?
2129                    .shape
2130            }
2131            158 => {
2132                let frame = frame_about(point(1), Direction::Z, tol)?;
2133                ogeom_algo::make_sphere(&mut self.model, frame, length(0), tol)?.shape
2134            }
2135            160 => {
2136                let frame = frame_about(point(2), direction(5, Vector::Z)?, tol)?;
2137                ogeom_algo::make_torus(&mut self.model, frame, length(0), length(1), tol)?.shape
2138            }
2139            162 => {
2140                // A planar profile turned about an axis by a fraction of a
2141                // full turn; an open profile (form 1) closed to the axis.
2142                let axis = ogeom_math::Axis::new(point(2), direction(5, Vector::Z)?);
2143                let fraction = match r(1) {
2144                    f if f > 0.0 => f.min(1.0),
2145                    _ => 1.0,
2146                };
2147                let face = self.profile_face(de, entity.at(0).int(), Some(axis))?;
2148                ogeom_algo::make_revolution(
2149                    &mut self.model,
2150                    &face,
2151                    axis,
2152                    core::f64::consts::TAU * fraction,
2153                    tol,
2154                )?
2155                .shape
2156            }
2157            164 => {
2158                let face = self.profile_face(de, entity.at(0).int(), None)?;
2159                let along = direction(2, Vector::Z)?.vector() * length(1);
2160                ogeom_algo::make_prism(&mut self.model, &face, along, tol)?.shape
2161            }
2162            168 => {
2163                // A ball of unit radius, stretched along the local axes.
2164                let x = direction(6, Vector::X)?.vector();
2165                let z = direction(9, Vector::Z)?.vector();
2166                let y = z.cross(x);
2167                let (a, b, c) = (length(0), length(1), length(2));
2168                let linear = Matrix3::new([
2169                    [x.x * a, y.x * b, z.x * c],
2170                    [x.y * a, y.y * b, z.y * c],
2171                    [x.z * a, y.z * b, z.z * c],
2172                ]);
2173                let ball = ogeom_algo::make_sphere(&mut self.model, Frame::WORLD, 1.0, tol)?.shape;
2174                let stretch = ogeom_math::GeneralTransform::new(linear, point(3).to_vector());
2175                ogeom_algo::general_transformed_shape(&mut self.model, &ball, &stretch, tol)?.shape
2176            }
2177            180 => {
2178                // Post-order: operands pushed, each operation code (1 union,
2179                // 2 intersection, 3 difference) combining the last two.
2180                let n = usize::try_from(entity.at(0).int()).unwrap_or(0);
2181                let mut stack: Vec<Shape> = Vec::new();
2182                for i in 0..n {
2183                    let item = entity.at(1 + i).int();
2184                    if item < 0 {
2185                        stack.push(self.csg(-item)?);
2186                        continue;
2187                    }
2188                    let (Some(b), Some(a)) = (stack.pop(), stack.pop()) else {
2189                        ogeom_bail!(
2190                            Construction,
2191                            "D{de}: a boolean tree's operation has too few operands"
2192                        );
2193                    };
2194                    let result = match item {
2195                        1 => ogeom_bool::fuse(&mut self.model, &a, &b, tol)?,
2196                        2 => ogeom_bool::common(&mut self.model, &a, &b, tol)?,
2197                        3 => ogeom_bool::cut(&mut self.model, &a, &b, tol)?,
2198                        code => ogeom_bail!(
2199                            Construction,
2200                            "D{de}: boolean operation code {code} names no operation"
2201                        ),
2202                    };
2203                    stack.push(result.shape);
2204                }
2205                let [solid] = stack.as_slice() else {
2206                    ogeom_bail!(
2207                        Construction,
2208                        "D{de}: a boolean tree leaves {} results",
2209                        stack.len()
2210                    );
2211                };
2212                solid.clone()
2213            }
2214            430 => self.csg(entity.at(0).int())?,
2215            186 => self.manifold_solid(de)?,
2216            kind => ogeom_bail!(
2217                Construction,
2218                "D{de}: type {kind} is not a constructive solid"
2219            ),
2220        };
2221        let placement = self.placement(entity)?;
2222        if placement == Transform::IDENTITY {
2223            return Ok(shape);
2224        }
2225        let location = ogeom_topo::Location::of(self.model.add_datum(placement));
2226        Ok(shape.moved(&location))
2227    }
2228
2229    /// A planar face bounded by a closed curve entity, or, where the curve
2230    /// is open and an axis is given, by the curve closed to the axis.
2231    fn profile_face(
2232        &mut self,
2233        de: i64,
2234        curve: i64,
2235        axis: Option<ogeom_math::Axis>,
2236    ) -> OgeomResult<Shape> {
2237        let mut segments = self.curve_segments(curve)?;
2238        let start = segments[0].0.point_at(segments[0].1.0, self.tol)?;
2239        let last = &segments[segments.len() - 1];
2240        let end = last.0.point_at(last.1.1, self.tol)?;
2241        if start.distance(end) > self.tol.confusion() * 100.0 {
2242            let Some(axis) = axis else {
2243                ogeom_bail!(Construction, "D{de}: an extruded profile does not close");
2244            };
2245            let foot = |p: Point| {
2246                let o = axis.location;
2247                let d = axis.direction.vector();
2248                o + d * (p - o).dot(d)
2249            };
2250            let (end_foot, start_foot) = (foot(end), foot(start));
2251            for (a, b) in [(end, end_foot), (end_foot, start_foot), (start_foot, start)] {
2252                if a.distance(b) > self.tol.confusion() * 100.0 {
2253                    let line: Curve = LineCurve::segment(a, b, self.tol)?.into();
2254                    let domain = line.domain();
2255                    segments.push((line, domain));
2256                }
2257            }
2258        }
2259        let edges = self.wire_edges(de, segments)?;
2260        let wire = ogeom_algo::make_wire(&mut self.model, &edges, self.tol)?.shape;
2261        let Some(plane) = ogeom_algo::find_plane(&self.model, &wire, self.tol)? else {
2262            ogeom_bail!(Construction, "D{de}: a solid's profile is not planar");
2263        };
2264        let surface: SurfaceGeometry = PlaneSurface::over(
2265            plane,
2266            (-SURFACE_EXTENT, SURFACE_EXTENT),
2267            (-SURFACE_EXTENT, SURFACE_EXTENT),
2268        )?
2269        .into();
2270        Ok(ogeom_algo::make_face_with_pcurves(&mut self.model, surface, &[edges], self.tol)?.shape)
2271    }
2272
2273    /// Every independent singular subfigure instance (408) placed: its
2274    /// definition's (308) solids moved into place, and its trimmed surfaces
2275    /// moved and sewn, closed shells becoming solids as a surface file's
2276    /// do. A definition is built once, however many instances place it:
2277    /// the instances share its topology under their own placements.
2278    #[allow(
2279        clippy::type_complexity,
2280        reason = "the solids, the sheets, and what built what"
2281    )]
2282    fn subfigure_instances(
2283        &mut self,
2284        tol: Tolerances,
2285    ) -> OgeomResult<(Vec<Shape>, Vec<Shape>, Vec<(i64, Shape)>)> {
2286        let instances: Vec<i64> = self
2287            .file
2288            .entities
2289            .iter()
2290            .filter(|(_, e)| e.kind == 408 && (e.status / 10_000) % 100 == 0)
2291            .map(|(de, _)| *de)
2292            .collect();
2293        let mut built: HashMap<i64, (Vec<Shape>, Vec<Shape>)> = HashMap::new();
2294        let (mut solids, mut sheets, mut from) = (Vec::new(), Vec::new(), Vec::new());
2295        for de in instances {
2296            let (placed_solids, faces) = match self.instance(de, &mut built, 0) {
2297                Ok(found) => found,
2298                Err(e) => {
2299                    self.report
2300                        .warnings
2301                        .push(format!("D{de}: subfigure instance not placed: {e}"));
2302                    continue;
2303                }
2304            };
2305            from.extend(placed_solids.iter().map(|s| (de, s.clone())));
2306            from.extend(faces.iter().map(|f| (de, f.clone())));
2307            solids.extend(placed_solids);
2308            if faces.is_empty() {
2309                continue;
2310            }
2311            let sewn = sew(&mut self.model, &faces, tol)?;
2312            for shell in &sewn.shells {
2313                if ogeom_algo::is_shell_closed(&self.model, shell)? {
2314                    solids.push(make_solid(&mut self.model, std::slice::from_ref(shell))?.shape);
2315                } else {
2316                    sheets.push(shell.clone());
2317                }
2318            }
2319        }
2320        Ok((solids, sheets, from))
2321    }
2322
2323    /// One instance's solids and faces, placed: its own placement, then
2324    /// the translation and uniform scale it states.
2325    fn instance(
2326        &mut self,
2327        de: i64,
2328        built: &mut HashMap<i64, (Vec<Shape>, Vec<Shape>)>,
2329        depth: usize,
2330    ) -> OgeomResult<(Vec<Shape>, Vec<Shape>)> {
2331        if depth > 32 {
2332            ogeom_bail!(
2333                Construction,
2334                "D{de}: subfigures nest past any sensible depth"
2335            );
2336        }
2337        let entity = self.entity(de)?;
2338        let definition = entity.at(0).int();
2339        let s = self.report.scale_mm;
2340        let offset = Vector::new(
2341            entity.at(1).real() * s,
2342            entity.at(2).real() * s,
2343            entity.at(3).real() * s,
2344        );
2345        let factor = match entity.at(4).real() {
2346            f if f > 0.0 => f,
2347            _ => 1.0,
2348        };
2349        let (solids, faces) = self.subfigure(definition, built, depth)?;
2350        let motion = self.placement(entity)?
2351            * Transform::translation(offset)
2352            * Transform::scaling(Point::ORIGIN, factor, self.tol)?;
2353        let location = ogeom_topo::Location::of(self.model.add_datum(motion));
2354        Ok((
2355            solids.iter().map(|x| x.moved(&location)).collect(),
2356            faces.iter().map(|x| x.moved(&location)).collect(),
2357        ))
2358    }
2359
2360    /// A subfigure definition's (308) solids and trimmed surfaces, at the
2361    /// definition's own coordinates, nested instances placed within it.
2362    fn subfigure(
2363        &mut self,
2364        de: i64,
2365        built: &mut HashMap<i64, (Vec<Shape>, Vec<Shape>)>,
2366        depth: usize,
2367    ) -> OgeomResult<(Vec<Shape>, Vec<Shape>)> {
2368        if let Some(found) = built.get(&de) {
2369            return Ok(found.clone());
2370        }
2371        let entity = self.entity(de)?;
2372        if entity.kind != 308 {
2373            ogeom_bail!(
2374                Construction,
2375                "D{de}: an instance names a type {} entity, not a subfigure \
2376                 definition",
2377                entity.kind
2378            );
2379        }
2380        let count = usize::try_from(entity.at(2).int()).unwrap_or(0);
2381        let (mut solids, mut faces) = (Vec::new(), Vec::new());
2382        for i in 0..count {
2383            let member = entity.at(3 + i).int();
2384            let Some(kind) = self.file.entity(member).map(|m| m.kind) else {
2385                continue;
2386            };
2387            match kind {
2388                186 => solids.push(self.manifold_solid(member)?),
2389                143 | 144 => faces.push(self.face(member)?),
2390                408 => {
2391                    let (s, f) = self.instance(member, built, depth + 1)?;
2392                    solids.extend(s);
2393                    faces.extend(f);
2394                }
2395                _ => {}
2396            }
2397        }
2398        built.insert(de, (solids.clone(), faces.clone()));
2399        Ok((solids, faces))
2400    }
2401
2402    /// The model-space annotation: every independent drafting entity a
2403    /// drawing does not own, as a callout of the lines the file draws (its
2404    /// leaders, witness lines and symbol geometry, carried by their own
2405    /// placements into the part's coordinates) named by its note's text.
2406    /// A dimension whose text reads as a number is also the semantic
2407    /// dimension of that value, which the callout draws; which geometry
2408    /// it measures the file does not say. A drawing's own annotation lives
2409    /// on the sheet, not on the part, and stays in the skipped table.
2410    fn annotations(
2411        &mut self,
2412    ) -> (
2413        Vec<ogeom_doc::Callout>,
2414        Vec<(ogeom_doc::Callout, ogeom_doc::Dimension)>,
2415    ) {
2416        let mut on_sheets: std::collections::BTreeSet<i64> = std::collections::BTreeSet::new();
2417        for entity in self.file.entities.values().filter(|e| e.kind == 404) {
2418            let views = usize::try_from(entity.at(0).int()).unwrap_or(0);
2419            let at = 1 + 3 * views;
2420            let count = usize::try_from(entity.at(at).int()).unwrap_or(0);
2421            for i in 0..count {
2422                on_sheets.insert(entity.at(at + 1 + i).int().abs());
2423            }
2424        }
2425        let drafting: Vec<i64> = self
2426            .file
2427            .entities
2428            .iter()
2429            .filter(|(de, e)| {
2430                matches!(
2431                    e.kind,
2432                    202 | 206 | 208 | 210 | 212 | 214 | 216 | 218 | 220 | 222 | 228
2433                ) && (e.status / 10_000) % 100 == 0
2434                    && !on_sheets.contains(de)
2435            })
2436            .map(|(de, _)| *de)
2437            .collect();
2438        let mut callouts = Vec::new();
2439        let mut dimensions = Vec::new();
2440        for de in drafting {
2441            match self.drafting(de) {
2442                Ok((callout, Some(dimension))) => dimensions.push((callout, dimension)),
2443                Ok((callout, None)) => callouts.push(callout),
2444                Err(e) => self
2445                    .report
2446                    .warnings
2447                    .push(format!("D{de}: annotation not drawn: {e}")),
2448            }
2449        }
2450        (callouts, dimensions)
2451    }
2452
2453    /// One drafting entity as a callout, and the dimension it states where
2454    /// it is one.
2455    fn drafting(
2456        &mut self,
2457        de: i64,
2458    ) -> OgeomResult<(ogeom_doc::Callout, Option<ogeom_doc::Dimension>)> {
2459        let entity = self.entity(de)?;
2460        let pointer = |i: usize| entity.at(i).int();
2461        let mut polylines: Vec<Vec<Point>> = Vec::new();
2462        let mut text = String::new();
2463        let mut measure: Option<(&str, ogeom_doc::MeasureKind)> = None;
2464        let draw = |reader: &mut Self, lines: &mut Vec<Vec<Point>>, de: i64| -> OgeomResult<()> {
2465            if de != 0 {
2466                lines.push(reader.drafted_line(de)?);
2467            }
2468            Ok(())
2469        };
2470        match entity.kind {
2471            212 => text = self.note_text(de)?,
2472            214 => polylines.push(self.drafted_line(de)?),
2473            216 => {
2474                text = self.note_text(pointer(0))?;
2475                for i in 1..=4 {
2476                    draw(self, &mut polylines, pointer(i))?;
2477                }
2478                measure = Some(("linear distance", ogeom_doc::MeasureKind::Length));
2479            }
2480            206 => {
2481                text = self.note_text(pointer(0))?;
2482                for i in 1..=2 {
2483                    draw(self, &mut polylines, pointer(i))?;
2484                }
2485                measure = Some(("diameter", ogeom_doc::MeasureKind::Length));
2486            }
2487            222 => {
2488                text = self.note_text(pointer(0))?;
2489                draw(self, &mut polylines, pointer(1))?;
2490                if entity.params.len() > 4 {
2491                    draw(self, &mut polylines, pointer(4))?;
2492                }
2493                measure = Some(("radius", ogeom_doc::MeasureKind::Length));
2494            }
2495            202 => {
2496                text = self.note_text(pointer(0))?;
2497                for i in [1, 2, 6, 7] {
2498                    draw(self, &mut polylines, pointer(i))?;
2499                }
2500                measure = Some(("angle", ogeom_doc::MeasureKind::Angle));
2501            }
2502            218 | 220 => {
2503                text = self.note_text(pointer(0))?;
2504                draw(self, &mut polylines, pointer(1))?;
2505                measure = Some(("ordinate", ogeom_doc::MeasureKind::Length));
2506                if entity.kind == 220 {
2507                    measure = None;
2508                }
2509            }
2510            208 | 210 => {
2511                // A flag note: its place and angle, then its note and
2512                // leaders; a label: its note, then its leaders.
2513                let at = if entity.kind == 208 { 4 } else { 0 };
2514                text = self.note_text(pointer(at))?;
2515                let n = usize::try_from(pointer(at + 1)).unwrap_or(0);
2516                for i in 0..n {
2517                    draw(self, &mut polylines, pointer(at + 2 + i))?;
2518                }
2519            }
2520            228 => {
2521                // Note, the symbol's geometry, then its leaders.
2522                text = self.note_text(pointer(0))?;
2523                let ng = usize::try_from(pointer(1)).unwrap_or(0);
2524                for i in 0..ng {
2525                    polylines.push(self.sampled_curve(pointer(2 + i))?);
2526                }
2527                let nl = usize::try_from(pointer(2 + ng)).unwrap_or(0);
2528                for i in 0..nl {
2529                    draw(self, &mut polylines, pointer(3 + ng + i))?;
2530                }
2531            }
2532            kind => ogeom_bail!(Construction, "type {kind} is not drafting"),
2533        }
2534        let placement = self.placement(entity)?;
2535        let plane = Frame::new(
2536            placement.apply(Point::ORIGIN),
2537            Direction::new(placement.apply_vector(Vector::Z), self.tol)?,
2538            Direction::new(placement.apply_vector(Vector::X), self.tol)?,
2539            self.tol,
2540        )
2541        .ok();
2542        let callout = ogeom_doc::Callout {
2543            name: text.clone(),
2544            plane,
2545            polylines,
2546            annotates: None,
2547        };
2548        let dimension = measure.and_then(|(name, kind)| {
2549            let value = first_number(&text)?;
2550            let value = match kind {
2551                ogeom_doc::MeasureKind::Angle => value.to_radians(),
2552                ogeom_doc::MeasureKind::Length => value * self.report.scale_mm,
2553            };
2554            Some(ogeom_doc::Dimension {
2555                name: name.to_owned(),
2556                values: vec![value],
2557                kind,
2558                plus: None,
2559                minus: None,
2560                features: Vec::new(),
2561                location: entity.kind == 216 || entity.kind == 218,
2562            })
2563        });
2564        Ok((callout, dimension))
2565    }
2566
2567    /// A general note's (212) text, its strings one line each.
2568    fn note_text(&mut self, de: i64) -> OgeomResult<String> {
2569        if de == 0 {
2570            return Ok(String::new());
2571        }
2572        let entity = self.entity(de)?;
2573        if entity.kind != 212 {
2574            ogeom_bail!(Construction, "D{de}: type {} is not a note", entity.kind);
2575        }
2576        let count = usize::try_from(entity.at(0).int()).unwrap_or(0);
2577        let mut lines = Vec::with_capacity(count);
2578        for i in 0..count {
2579            if let super::parse::Value::Text(t) = entity.at(1 + 12 * i + 11) {
2580                lines.push(t.clone());
2581            }
2582        }
2583        Ok(lines.join("\n"))
2584    }
2585
2586    /// A leader (214) or witness line (106) as the polyline it draws, in
2587    /// the part's coordinates.
2588    fn drafted_line(&mut self, de: i64) -> OgeomResult<Vec<Point>> {
2589        let entity = self.entity(de)?;
2590        let s = self.report.scale_mm;
2591        let mut points = Vec::new();
2592        match entity.kind {
2593            214 => {
2594                let n = usize::try_from(entity.at(0).int()).unwrap_or(0);
2595                let z = entity.at(3).real();
2596                points.push(Point::new(
2597                    entity.at(4).real() * s,
2598                    entity.at(5).real() * s,
2599                    z * s,
2600                ));
2601                for i in 0..n {
2602                    points.push(Point::new(
2603                        entity.at(6 + 2 * i).real() * s,
2604                        entity.at(7 + 2 * i).real() * s,
2605                        z * s,
2606                    ));
2607                }
2608            }
2609            106 => {
2610                let n = usize::try_from(entity.at(1).int()).unwrap_or(0);
2611                let z = entity.at(2).real();
2612                for i in 0..n {
2613                    points.push(Point::new(
2614                        entity.at(3 + 2 * i).real() * s,
2615                        entity.at(4 + 2 * i).real() * s,
2616                        z * s,
2617                    ));
2618                }
2619            }
2620            _ => return self.sampled_curve(de),
2621        }
2622        let placement = self.placement(entity)?;
2623        Ok(points.into_iter().map(|p| placement.apply(p)).collect())
2624    }
2625
2626    /// Any curve the reader translates, as a polyline for drawing.
2627    fn sampled_curve(&mut self, de: i64) -> OgeomResult<Vec<Point>> {
2628        let (curve, range) = self.curve(de)?;
2629        (0..=32)
2630            .map(|i| {
2631                curve.point_at(
2632                    range.0 + (range.1 - range.0) * f64::from(i) / 32.0,
2633                    self.tol,
2634                )
2635            })
2636            .collect()
2637    }
2638
2639    fn document(&mut self, solids: &[(i64, Shape)], sheets: &[Shape]) -> ogeom_doc::Document {
2640        let colours: Vec<(Shape, ogeom_doc::Colour)> = solids
2641            .iter()
2642            .filter_map(|(de, s)| self.colour_of(*de).map(|c| (s.clone(), c)))
2643            .collect();
2644        let mut document = ogeom_doc::Document::over(std::mem::take(&mut self.model));
2645        for (i, (de, solid)) in solids.iter().enumerate() {
2646            let label = self
2647                .file
2648                .entity(*de)
2649                .map(|e| e.label.clone())
2650                .filter(|l| !l.is_empty())
2651                .unwrap_or_else(|| format!("solid-{i}"));
2652            document.add_part(label, solid.clone());
2653        }
2654        for (i, sheet) in sheets.iter().enumerate() {
2655            document.add_part(format!("sheet-{i}"), sheet.clone());
2656        }
2657        for (shape, colour) in colours {
2658            document.set_colour(&shape, colour);
2659        }
2660        document
2661    }
2662
2663    /// The colour a directory entry states: a negated field points at a 314
2664    /// entity's RGB percentages; a small positive names the fixed palette.
2665    fn colour_of(&mut self, de: i64) -> Option<ogeom_doc::Colour> {
2666        let entity = self.file.entity(de)?;
2667        let c = entity.colour;
2668        if c < 0 {
2669            let e = self.file.entity(-c)?;
2670            self.visited.insert(-c, ());
2671            if e.kind != 314 {
2672                return None;
2673            }
2674            return Some(ogeom_doc::Colour::rgb(
2675                (e.at(0).real() / 100.0).clamp(0.0, 1.0),
2676                (e.at(1).real() / 100.0).clamp(0.0, 1.0),
2677                (e.at(2).real() / 100.0).clamp(0.0, 1.0),
2678            ));
2679        }
2680        // 1 black, 2 red, 3 green, 4 blue, 5 yellow, 6 magenta, 7 cyan,
2681        // 8 white: the specification's own palette.
2682        let palette = [
2683            (0.0, 0.0, 0.0),
2684            (1.0, 0.0, 0.0),
2685            (0.0, 1.0, 0.0),
2686            (0.0, 0.0, 1.0),
2687            (1.0, 1.0, 0.0),
2688            (1.0, 0.0, 1.0),
2689            (0.0, 1.0, 1.0),
2690            (1.0, 1.0, 1.0),
2691        ];
2692        let index = usize::try_from(c).ok()?.checked_sub(1)?;
2693        palette
2694            .get(index)
2695            .map(|&(r, g, b)| ogeom_doc::Colour::rgb(r, g, b))
2696    }
2697}
2698
2699/// A trimmed carrier where the range is a strict part of the domain: a
2700/// generatrix used by a sweep is exactly its stated span.
2701/// The file's levels and groups as the document's layers: every shape on
2702/// the layer of the level its entity sits on (a negative level names a
2703/// definition-levels property listing several), and every group (402,
2704/// forms 1, 7, 14 and 15) a layer named by the group's label holding the
2705/// shapes its members built.
2706fn layers(
2707    file: &File,
2708    document: &mut ogeom_doc::Document,
2709    built_from: &[(i64, Shape)],
2710    report: &mut IgesReport,
2711) {
2712    let mut by_name: HashMap<String, ogeom_doc::LayerId> = HashMap::new();
2713    let mut layer = |document: &mut ogeom_doc::Document, name: String| {
2714        *by_name
2715            .entry(name.clone())
2716            .or_insert_with(|| document.add_layer(name))
2717    };
2718    for (de, shape) in built_from {
2719        let Some(entity) = file.entity(*de) else {
2720            continue;
2721        };
2722        let levels: Vec<i64> = if entity.level > 0 {
2723            vec![entity.level]
2724        } else if entity.level < 0 {
2725            match file.entity(-entity.level) {
2726                Some(p) if p.kind == 406 && p.form == 1 => {
2727                    let n = usize::try_from(p.at(0).int()).unwrap_or(0);
2728                    (0..n).map(|i| p.at(1 + i).int()).collect()
2729                }
2730                _ => {
2731                    report
2732                        .warnings
2733                        .push(format!("D{de}: its level names no levels property"));
2734                    Vec::new()
2735                }
2736            }
2737        } else {
2738            Vec::new()
2739        };
2740        for level in levels {
2741            let id = layer(document, format!("level {level}"));
2742            document.place_on_layer(shape, id);
2743        }
2744    }
2745    for (de, group) in &file.entities {
2746        if group.kind != 402 || !matches!(group.form, 1 | 7 | 14 | 15) {
2747            continue;
2748        }
2749        let n = usize::try_from(group.at(0).int()).unwrap_or(0);
2750        let members: Vec<i64> = (0..n).map(|i| group.at(1 + i).int().abs()).collect();
2751        let shapes: Vec<&Shape> = built_from
2752            .iter()
2753            .filter(|(from, _)| members.contains(from))
2754            .map(|(_, s)| s)
2755            .collect();
2756        if shapes.is_empty() {
2757            continue;
2758        }
2759        let name = if group.label.is_empty() {
2760            format!("group D{de}")
2761        } else {
2762            group.label.clone()
2763        };
2764        let id = layer(document, name);
2765        for shape in shapes {
2766            document.place_on_layer(shape, id);
2767        }
2768    }
2769}
2770
2771/// The value a dimension's text states: `R5.5`, `Ø10`, `45°` read as 5.5,
2772/// 10 and 45, and a patterned callout's leading count (`2X Ø5`, `4 x 12.7`)
2773/// passed over for the value after it.
2774fn first_number(text: &str) -> Option<f64> {
2775    let trimmed = text.trim_start();
2776    let count = trimmed
2777        .find(|c: char| !c.is_ascii_digit())
2778        .filter(|&n| n > 0)
2779        .and_then(|n| {
2780            let rest = trimmed[n..].trim_start();
2781            rest.strip_prefix(['X', 'x']).map(|after| after.len())
2782        });
2783    let text = match count {
2784        Some(len) => &trimmed[trimmed.len() - len..],
2785        None => text,
2786    };
2787    let start = text.find(|c: char| c.is_ascii_digit() || c == '.')?;
2788    let tail = &text[start..];
2789    let end = tail
2790        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
2791        .unwrap_or(tail.len());
2792    tail[..end].parse().ok()
2793}
2794
2795fn trimmed_to(curve: Curve, range: (f64, f64), tol: Tolerances) -> OgeomResult<Curve> {
2796    let (lo, hi) = curve.domain();
2797    if (range.0 - lo).abs() < tol.parametric() && (range.1 - hi).abs() < tol.parametric() {
2798        return Ok(curve);
2799    }
2800    Ok(Curve::from(TrimmedCurve::new(
2801        curve, range.0, range.1, tol,
2802    )?))
2803}
2804
2805use crate::inversion::parameter_on;
2806
2807/// A frame with the given axis direction, reference direction chosen stably.
2808fn frame_about(origin: Point, axis: Direction, tol: Tolerances) -> OgeomResult<Frame> {
2809    let seed = if axis.vector().dot(Vector::X).abs() < 0.9 {
2810        Vector::X
2811    } else {
2812        Vector::Y
2813    };
2814    let x = Direction::from_cross(axis.vector(), seed, tol)?;
2815    Frame::new(origin, axis, x, tol)
2816}
2817
2818#[cfg(test)]
2819#[allow(clippy::unwrap_used)]
2820mod tests {
2821    use super::super::parse::Value;
2822    use super::*;
2823
2824    const T: Tolerances = Tolerances::millimetres();
2825
2826    fn entity(kind: i64, form: i64, params: Vec<Value>) -> Entity {
2827        Entity {
2828            kind,
2829            form,
2830            transform: 0,
2831            colour: 0,
2832            level: 0,
2833            status: 0,
2834            label: String::new(),
2835            params,
2836        }
2837    }
2838
2839    fn file(entities: Vec<(i64, Entity)>) -> File {
2840        let mut global = vec![Value::Default; 16];
2841        global[13] = Value::Int(2);
2842        File {
2843            global,
2844            entities: entities.into_iter().collect(),
2845        }
2846    }
2847
2848    fn reader(file: &File) -> Reader<'_> {
2849        Reader {
2850            file,
2851            model: Model::new(),
2852            report: IgesReport {
2853                scale_mm: 1.0,
2854                ..IgesReport::default()
2855            },
2856            visited: BTreeMap::new(),
2857            vertices: HashMap::new(),
2858            edges: HashMap::new(),
2859            vertex_misses: (0, 0.0),
2860            tol: T,
2861        }
2862    }
2863
2864    fn reals(values: &[f64]) -> Vec<Value> {
2865        values.iter().map(|v| Value::Real(*v)).collect()
2866    }
2867
2868    fn line(from: [f64; 3], to: [f64; 3]) -> Entity {
2869        entity(
2870            110,
2871            0,
2872            reals(&[from[0], from[1], from[2], to[0], to[1], to[2]]),
2873        )
2874    }
2875
2876    /// A conic arc whose squares differ in sign is a hyperbola, and one
2877    /// missing a square is a parabola; each reads as its own curve, the arc
2878    /// running from the file's start to its terminate point whichever way
2879    /// round the file lists them.
2880    #[test]
2881    fn hyperbola_and_parabola_conic_arcs_read_as_their_curves() {
2882        let (cosh1, sinh1) = (1.0_f64.cosh(), 1.0_f64.sinh());
2883        // x²/4 − y²/9 = 1, the arc from the vertex to parameter one.
2884        let hyperbola = |forward: bool| {
2885            let (s, e) = if forward {
2886                ([2.0, 0.0], [2.0 * cosh1, 3.0 * sinh1])
2887            } else {
2888                ([2.0 * cosh1, 3.0 * sinh1], [2.0, 0.0])
2889            };
2890            entity(
2891                104,
2892                2,
2893                reals(&[
2894                    0.25,
2895                    0.0,
2896                    -1.0 / 9.0,
2897                    0.0,
2898                    0.0,
2899                    -1.0,
2900                    0.0,
2901                    s[0],
2902                    s[1],
2903                    e[0],
2904                    e[1],
2905                ]),
2906            )
2907        };
2908        // x² − 4 y = 0 opening along y, and y² − 4 x = 0 opening along x.
2909        let parabola_y = entity(
2910            104,
2911            3,
2912            reals(&[1.0, 0.0, 0.0, 0.0, -4.0, 0.0, 0.0, 0.0, 0.0, 4.0, 4.0]),
2913        );
2914        let parabola_x = entity(
2915            104,
2916            3,
2917            reals(&[0.0, 0.0, 1.0, -4.0, 0.0, 0.0, 0.0, 4.0, 4.0, 0.0, 0.0]),
2918        );
2919        let deck = file(vec![
2920            (1, hyperbola(true)),
2921            (3, hyperbola(false)),
2922            (5, parabola_y),
2923            (7, parabola_x),
2924        ]);
2925        let mut reader = reader(&deck);
2926        for de in [1, 3] {
2927            let (curve, (t0, t1)) = reader.curve(de).unwrap();
2928            assert!(matches!(curve, Curve::Hyperbola(_)), "D{de} is a hyperbola");
2929            assert!(t1 > t0, "the arc runs forward: {t0} .. {t1}");
2930            let entity = deck.entity(de).unwrap();
2931            let start = Point::new(entity.at(7).real(), entity.at(8).real(), 0.0);
2932            let end = Point::new(entity.at(9).real(), entity.at(10).real(), 0.0);
2933            assert!(curve.point_at(t0, T).unwrap().distance(start) < 1e-9);
2934            assert!(curve.point_at(t1, T).unwrap().distance(end) < 1e-9);
2935            let mid = curve.point_at(f64::midpoint(t0, t1), T).unwrap();
2936            assert!((mid.x * mid.x / 4.0 - mid.y * mid.y / 9.0 - 1.0).abs() < 1e-9);
2937        }
2938        for (de, along_y) in [(5, true), (7, false)] {
2939            let (curve, (t0, t1)) = reader.curve(de).unwrap();
2940            assert!(matches!(curve, Curve::Parabola(_)), "D{de} is a parabola");
2941            assert!(t1 > t0);
2942            let (start, end) = if along_y {
2943                (Point::ORIGIN, Point::new(4.0, 4.0, 0.0))
2944            } else {
2945                (Point::new(4.0, 4.0, 0.0), Point::ORIGIN)
2946            };
2947            assert!(curve.point_at(t0, T).unwrap().distance(start) < 1e-9);
2948            assert!(curve.point_at(t1, T).unwrap().distance(end) < 1e-9);
2949            let mid = curve.point_at(f64::midpoint(t0, t1), T).unwrap();
2950            let residual = if along_y {
2951                mid.x * mid.x - 4.0 * mid.y
2952            } else {
2953                mid.y * mid.y - 4.0 * mid.x
2954            };
2955            assert!(residual.abs() < 1e-9, "on the parabola: {mid:?}");
2956        }
2957    }
2958
2959    /// A ruled surface between two lines is the bilinear patch between
2960    /// them, the second line walked backward where the direction flag says.
2961    #[test]
2962    fn a_ruled_surface_reads_as_the_patch_between_its_curves() {
2963        let deck = file(vec![
2964            (1, line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])),
2965            (3, line([0.0, 5.0, 2.0], [10.0, 5.0, 2.0])),
2966            (
2967                5,
2968                entity(
2969                    118,
2970                    1,
2971                    vec![Value::Int(1), Value::Int(3), Value::Int(0), Value::Int(0)],
2972                ),
2973            ),
2974            (
2975                7,
2976                entity(
2977                    118,
2978                    1,
2979                    vec![Value::Int(1), Value::Int(3), Value::Int(1), Value::Int(0)],
2980                ),
2981            ),
2982        ]);
2983        let mut reader = reader(&deck);
2984        let straight = reader.surface(5).unwrap();
2985        let turned = reader.surface(7).unwrap();
2986        use ogeom_geom::Surface as _;
2987        let middle = straight.point_at(0.5, 0.5, T).unwrap();
2988        assert!(
2989            middle.distance(Point::new(5.0, 2.5, 1.0)) < 1e-9,
2990            "{middle:?}"
2991        );
2992        let far = straight.point_at(0.0, 1.0, T).unwrap();
2993        assert!(far.distance(Point::new(0.0, 5.0, 2.0)) < 1e-9, "{far:?}");
2994        let far = turned.point_at(0.0, 1.0, T).unwrap();
2995        assert!(far.distance(Point::new(10.0, 5.0, 2.0)) < 1e-9, "{far:?}");
2996    }
2997
2998    /// An offset surface displaces its base along the file's direction,
2999    /// and an offset curve along the file's reference crossed with the
3000    /// tangent, whichever way round this vocabulary spells either.
3001    #[test]
3002    fn offset_entities_displace_the_way_the_file_says() {
3003        let deck = file(vec![
3004            (1, entity(108, 0, reals(&[0.0, 0.0, 1.0, 0.0]))),
3005            (
3006                3,
3007                entity(
3008                    140,
3009                    0,
3010                    vec![
3011                        Value::Real(0.0),
3012                        Value::Real(0.0),
3013                        Value::Real(1.0),
3014                        Value::Real(3.0),
3015                        Value::Int(1),
3016                    ],
3017                ),
3018            ),
3019            (
3020                5,
3021                entity(
3022                    140,
3023                    0,
3024                    vec![
3025                        Value::Real(0.0),
3026                        Value::Real(0.0),
3027                        Value::Real(-1.0),
3028                        Value::Real(3.0),
3029                        Value::Int(1),
3030                    ],
3031                ),
3032            ),
3033            (7, line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])),
3034            (
3035                9,
3036                entity(
3037                    130,
3038                    0,
3039                    vec![
3040                        Value::Int(7),
3041                        Value::Int(1),
3042                        Value::Int(0),
3043                        Value::Int(3),
3044                        Value::Int(0),
3045                        Value::Real(2.0),
3046                        Value::Real(0.0),
3047                        Value::Real(2.0),
3048                        Value::Real(0.0),
3049                        Value::Real(0.0),
3050                        Value::Real(0.0),
3051                        Value::Real(1.0),
3052                        Value::Real(0.0),
3053                        Value::Real(10.0),
3054                    ],
3055                ),
3056            ),
3057        ]);
3058        let mut reader = reader(&deck);
3059        use ogeom_geom::Surface as _;
3060        let up = reader.surface(3).unwrap();
3061        assert!((up.point_at(1.0, 2.0, T).unwrap().z - 3.0).abs() < 1e-9);
3062        let down = reader.surface(5).unwrap();
3063        assert!((down.point_at(1.0, 2.0, T).unwrap().z + 3.0).abs() < 1e-9);
3064        let (offset, (t0, t1)) = reader.curve(9).unwrap();
3065        assert!(matches!(offset, Curve::Offset(_)));
3066        assert!((t0, t1) == (0.0, 10.0));
3067        let at = offset.point_at(5.0, T).unwrap();
3068        assert!(at.distance(Point::new(5.0, 2.0, 0.0)) < 1e-9, "{at:?}");
3069    }
3070
3071    /// A conic whose axes turn reads as the ellipse it is: `x'²/16 + y'²/4
3072    /// = 1` turned thirty degrees, written with its cross term.
3073    #[test]
3074    fn a_rotated_conic_reads_as_its_turned_curve() {
3075        let theta = 30.0_f64.to_radians();
3076        let (sin, cos) = theta.sin_cos();
3077        let (a2, b2) = (16.0, 4.0);
3078        let coefficients = [
3079            cos * cos / a2 + sin * sin / b2,
3080            2.0 * sin * cos * (1.0 / a2 - 1.0 / b2),
3081            sin * sin / a2 + cos * cos / b2,
3082            0.0,
3083            0.0,
3084            -1.0,
3085        ];
3086        let start = [4.0 * cos, 4.0 * sin];
3087        let end = [-2.0 * sin, 2.0 * cos];
3088        let mut values = coefficients.to_vec();
3089        values.extend([0.0, start[0], start[1], end[0], end[1]]);
3090        let deck = file(vec![(1, entity(104, 1, reals(&values)))]);
3091        let mut reader = reader(&deck);
3092        let (curve, (t0, t1)) = reader.curve(1).unwrap();
3093        assert!(matches!(curve, Curve::Ellipse(_)), "a turned ellipse");
3094        assert!(
3095            curve
3096                .point_at(t0, T)
3097                .unwrap()
3098                .distance(Point::new(start[0], start[1], 0.0))
3099                < 1e-9
3100        );
3101        assert!(
3102            curve
3103                .point_at(t1, T)
3104                .unwrap()
3105                .distance(Point::new(end[0], end[1], 0.0))
3106                < 1e-9
3107        );
3108        for k in 0..=8 {
3109            let p = curve
3110                .point_at(t0 + (t1 - t0) * f64::from(k) / 8.0, T)
3111                .unwrap();
3112            let [a, b, c, _, _, f] = coefficients;
3113            let residual = a * p.x * p.x + b * p.x * p.y + c * p.y * p.y + f;
3114            assert!(residual.abs() < 1e-9, "on the conic: {p:?}");
3115        }
3116    }
3117
3118    /// An offset growing linearly with arc length (type 2) from one at the
3119    /// base line's start to three at its end: the displaced curve rises
3120    /// along the reference crossed with the tangent.
3121    #[test]
3122    fn a_linearly_varying_offset_curve_reads_as_its_fitted_displacement() {
3123        let deck = file(vec![
3124            (1, line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])),
3125            (
3126                3,
3127                entity(
3128                    130,
3129                    0,
3130                    vec![
3131                        Value::Int(1),
3132                        Value::Int(2),
3133                        Value::Int(0),
3134                        Value::Int(0),
3135                        Value::Int(0),
3136                        Value::Real(1.0),
3137                        Value::Real(0.0),
3138                        Value::Real(3.0),
3139                        Value::Real(10.0),
3140                        Value::Real(0.0),
3141                        Value::Real(0.0),
3142                        Value::Real(1.0),
3143                        Value::Real(0.0),
3144                        Value::Real(0.0),
3145                    ],
3146                ),
3147            ),
3148        ]);
3149        let mut reader = reader(&deck);
3150        let (curve, (t0, t1)) = reader.curve(3).unwrap();
3151        for k in 0..=10 {
3152            let t = t0 + (t1 - t0) * f64::from(k) / 10.0;
3153            let p = curve.point_at(t, T).unwrap();
3154            let want = Point::new(p.x, 1.0 + 2.0 * p.x / 10.0, 0.0);
3155            assert!(p.distance(want) < 1e-5, "{p:?} against {want:?}");
3156        }
3157        assert!(
3158            curve
3159                .point_at(t0, T)
3160                .unwrap()
3161                .distance(Point::new(0.0, 1.0, 0.0))
3162                < 1e-5
3163        );
3164        assert!(
3165            curve
3166                .point_at(t1, T)
3167                .unwrap()
3168                .distance(Point::new(10.0, 3.0, 0.0))
3169                < 1e-5
3170        );
3171    }
3172
3173    /// The parametric spline surface `(s, t, s·t)`: one patch over
3174    /// `[0, 2] × [0, 3]`, and the same surface as two patches split at
3175    /// `s = 1` (the second re-expanded about its own break point). Both
3176    /// read as the exact bicubic B-spline through the same points.
3177    #[test]
3178    fn a_parametric_spline_surface_reads_as_its_exact_bspline() {
3179        let patch = |x: &[(usize, f64)], y: &[(usize, f64)], z: &[(usize, f64)]| -> Vec<f64> {
3180            let mut out = vec![0.0; 48];
3181            for (axis, terms) in [x, y, z].iter().enumerate() {
3182                for &(k, v) in *terms {
3183                    out[16 * axis + k] = v;
3184                }
3185            }
3186            out
3187        };
3188        let header = |m: i64, n: i64, tu: &[f64], tv: &[f64]| -> Vec<Value> {
3189            let mut out = vec![Value::Int(3), Value::Int(0), Value::Int(m), Value::Int(n)];
3190            out.extend(reals(tu));
3191            out.extend(reals(tv));
3192            out
3193        };
3194        // x = s, y = t, z = s·t: index 1 is s, 4 is t, 5 is s·t.
3195        let whole = {
3196            let mut v = header(1, 1, &[0.0, 2.0], &[0.0, 3.0]);
3197            v.extend(reals(&patch(&[(1, 1.0)], &[(4, 1.0)], &[(5, 1.0)])));
3198            v.extend(reals(&[0.0; 48]));
3199            v.extend(reals(&[0.0; 96]));
3200            v
3201        };
3202        // Split at s = 1: the second patch about s = 1 is x = 1 + s',
3203        // z = t + s'·t.
3204        let split = {
3205            let mut v = header(2, 1, &[0.0, 1.0, 2.0], &[0.0, 3.0]);
3206            v.extend(reals(&patch(&[(1, 1.0)], &[(4, 1.0)], &[(5, 1.0)])));
3207            v.extend(reals(&[0.0; 48]));
3208            v.extend(reals(&patch(
3209                &[(0, 1.0), (1, 1.0)],
3210                &[(4, 1.0)],
3211                &[(4, 1.0), (5, 1.0)],
3212            )));
3213            v.extend(reals(&[0.0; 48]));
3214            v.extend(reals(&[0.0; 96]));
3215            v
3216        };
3217        let deck = file(vec![(1, entity(114, 0, whole)), (3, entity(114, 0, split))]);
3218        let mut reader = reader(&deck);
3219        for de in [1, 3] {
3220            let surface = reader.surface(de).unwrap();
3221            for (u, v) in [(0.0, 0.0), (0.5, 1.0), (1.0, 2.5), (1.5, 0.7), (2.0, 3.0)] {
3222                let p = ogeom_geom::Surface::point_at(&surface, u, v, T).unwrap();
3223                let want = Point::new(u, v, u * v);
3224                assert!(p.distance(want) < 1e-12, "D{de} at ({u}, {v}): {p:?}");
3225            }
3226        }
3227    }
3228
3229    /// A curve-on-surface is creation, surface, parameter-space curve,
3230    /// model-space curve: the model curve is read where the file gives it,
3231    /// and a trim given only in the surface's parameters is lifted through
3232    /// a B-spline surface.
3233    #[test]
3234    fn a_parameter_space_trim_lifts_through_its_bspline_surface() {
3235        // The bilinear patch (10u, 10v, 0) over the unit square.
3236        let surface = entity(
3237            128,
3238            0,
3239            vec![
3240                Value::Int(1),
3241                Value::Int(1),
3242                Value::Int(1),
3243                Value::Int(1),
3244                Value::Int(0),
3245                Value::Int(0),
3246                Value::Int(1),
3247                Value::Int(0),
3248                Value::Int(0),
3249                Value::Real(0.0),
3250                Value::Real(0.0),
3251                Value::Real(1.0),
3252                Value::Real(1.0),
3253                Value::Real(0.0),
3254                Value::Real(0.0),
3255                Value::Real(1.0),
3256                Value::Real(1.0),
3257                Value::Real(1.0),
3258                Value::Real(1.0),
3259                Value::Real(1.0),
3260                Value::Real(1.0),
3261                Value::Real(0.0),
3262                Value::Real(0.0),
3263                Value::Real(0.0),
3264                Value::Real(10.0),
3265                Value::Real(0.0),
3266                Value::Real(0.0),
3267                Value::Real(0.0),
3268                Value::Real(10.0),
3269                Value::Real(0.0),
3270                Value::Real(10.0),
3271                Value::Real(10.0),
3272                Value::Real(0.0),
3273                Value::Real(0.0),
3274                Value::Real(1.0),
3275                Value::Real(0.0),
3276                Value::Real(1.0),
3277            ],
3278        );
3279        let on_surface = |model_curve: i64| {
3280            entity(
3281                142,
3282                0,
3283                vec![
3284                    Value::Int(0),
3285                    Value::Int(1),
3286                    Value::Int(3),
3287                    Value::Int(model_curve),
3288                    Value::Int(0),
3289                ],
3290            )
3291        };
3292        let deck = file(vec![
3293            (1, surface),
3294            (3, line([0.2, 0.2, 0.0], [0.8, 0.8, 0.0])),
3295            (5, on_surface(0)),
3296            (7, line([2.0, 2.0, 0.0], [8.0, 8.0, 0.0])),
3297            (9, on_surface(7)),
3298        ]);
3299        let mut reader = reader(&deck);
3300        for de in [5, 9] {
3301            let segments = reader.boundary_segments(de).unwrap();
3302            let [(curve, (t0, t1))] = segments.as_slice() else {
3303                panic!("one segment");
3304            };
3305            assert!(
3306                curve
3307                    .point_at(*t0, T)
3308                    .unwrap()
3309                    .distance(Point::new(2.0, 2.0, 0.0))
3310                    < 1e-6
3311            );
3312            assert!(
3313                curve
3314                    .point_at(*t1, T)
3315                    .unwrap()
3316                    .distance(Point::new(8.0, 8.0, 0.0))
3317                    < 1e-6
3318            );
3319            let mid = curve.point_at(f64::midpoint(*t0, *t1), T).unwrap();
3320            assert!(
3321                (mid.x - mid.y).abs() < 1e-6 && mid.z.abs() < 1e-9,
3322                "D{de} {mid:?}"
3323            );
3324        }
3325    }
3326
3327    /// A trim given in an analytic surface's own parameters lifts through
3328    /// the format's parameterization: a cylinder's angle in degrees and
3329    /// height, a sphere's two angles, a torus's pair turned; each angle
3330    /// measured from the surface's reference direction.
3331    #[test]
3332    fn a_parameter_space_trim_lifts_through_an_analytic_surface() {
3333        let point = |p: [f64; 3]| entity(116, 0, reals(&p));
3334        let direction = |d: [f64; 3]| entity(123, 0, reals(&d));
3335        let on_surface = |surface: i64| {
3336            entity(
3337                142,
3338                0,
3339                vec![
3340                    Value::Int(0),
3341                    Value::Int(surface),
3342                    Value::Int(3),
3343                    Value::Int(0),
3344                    Value::Int(0),
3345                ],
3346            )
3347        };
3348        // Location, axis +z, reference +y (so angle zero points along +y).
3349        type Case = (Entity, [f64; 2], [f64; 2], Point, Point);
3350        let cases: Vec<Case> = vec![
3351            (
3352                // Cylinder radius 2: angle 0 -> 90 degrees, height 0 -> 5.
3353                entity(
3354                    192,
3355                    1,
3356                    vec![
3357                        Value::Int(11),
3358                        Value::Int(13),
3359                        Value::Real(2.0),
3360                        Value::Int(15),
3361                    ],
3362                ),
3363                [0.0, 0.0],
3364                [90.0, 5.0],
3365                Point::new(0.0, 2.0, 0.0),
3366                Point::new(-2.0, 0.0, 5.0),
3367            ),
3368            (
3369                // Sphere radius 3: from (0, 0) to (90 degrees, 90 degrees), the pole.
3370                entity(
3371                    196,
3372                    1,
3373                    vec![
3374                        Value::Int(11),
3375                        Value::Real(3.0),
3376                        Value::Int(13),
3377                        Value::Int(15),
3378                    ],
3379                ),
3380                [0.0, 0.0],
3381                [90.0, 45.0],
3382                Point::new(0.0, 3.0, 0.0),
3383                Point::new(
3384                    -3.0 * core::f64::consts::FRAC_1_SQRT_2,
3385                    0.0,
3386                    3.0 * core::f64::consts::FRAC_1_SQRT_2,
3387                ),
3388            ),
3389            (
3390                // Torus 5 by 1: IGES (u round the tube, v round the axis
3391                // measured back from a full turn). (0, 360) is the outer
3392                // equator at angle zero; (90, 270) a quarter round each.
3393                entity(
3394                    198,
3395                    1,
3396                    vec![
3397                        Value::Int(11),
3398                        Value::Int(13),
3399                        Value::Real(5.0),
3400                        Value::Real(1.0),
3401                        Value::Int(15),
3402                    ],
3403                ),
3404                [0.0, 360.0],
3405                [90.0, 270.0],
3406                Point::new(0.0, 6.0, 0.0),
3407                Point::new(-5.0, 0.0, 1.0),
3408            ),
3409        ];
3410        for (surface, from, to, at_from, at_to) in cases {
3411            let deck = file(vec![
3412                (1, surface),
3413                (3, line([from[0], from[1], 0.0], [to[0], to[1], 0.0])),
3414                (5, on_surface(1)),
3415                (11, point([0.0, 0.0, 0.0])),
3416                (13, direction([0.0, 0.0, 1.0])),
3417                (15, direction([0.0, 1.0, 0.0])),
3418            ]);
3419            let mut reader = reader(&deck);
3420            let segments = reader.boundary_segments(5).unwrap();
3421            let [(curve, (t0, t1))] = segments.as_slice() else {
3422                panic!("one segment");
3423            };
3424            let (a, b) = (
3425                curve.point_at(*t0, T).unwrap(),
3426                curve.point_at(*t1, T).unwrap(),
3427            );
3428            assert!(a.distance(at_from) < 1e-6, "{a:?} against {at_from:?}");
3429            assert!(b.distance(at_to) < 1e-6, "{b:?} against {at_to:?}");
3430        }
3431    }
3432
3433    /// A tabulated cylinder's trim is in fractions of its directrix and
3434    /// generator; a surface of revolution's in its generatrix's own
3435    /// parameter (a line's over [0, 1]) and the angle turned.
3436    #[test]
3437    fn a_parameter_space_trim_lifts_through_a_swept_surface() {
3438        let on_surface = |surface: i64| {
3439            entity(
3440                142,
3441                0,
3442                vec![
3443                    Value::Int(0),
3444                    Value::Int(surface),
3445                    Value::Int(3),
3446                    Value::Int(0),
3447                    Value::Int(0),
3448                ],
3449            )
3450        };
3451        // 122: the directrix (0,0,0)-(10,0,0), generator to (0,0,4): the
3452        // parameters (0.5, 0) and (0.5, 1) are (5,0,0) and (5,0,4).
3453        let tabulated = entity(
3454            122,
3455            0,
3456            vec![
3457                Value::Int(7),
3458                Value::Real(0.0),
3459                Value::Real(0.0),
3460                Value::Real(4.0),
3461            ],
3462        );
3463        // 120: the axis along z through the origin, the generatrix the
3464        // line (2,0,0)-(2,0,6) turned from 0 to a quarter turn: (0, 0) is
3465        // (2,0,0) and (1, pi/2) is (0,2,6).
3466        let revolved = entity(
3467            120,
3468            0,
3469            vec![
3470                Value::Int(9),
3471                Value::Int(7),
3472                Value::Real(0.0),
3473                Value::Real(core::f64::consts::FRAC_PI_2),
3474            ],
3475        );
3476        let cases = [
3477            (
3478                tabulated,
3479                line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0]),
3480                [0.5, 0.0],
3481                [0.5, 1.0],
3482                Point::new(5.0, 0.0, 0.0),
3483                Point::new(5.0, 0.0, 4.0),
3484            ),
3485            (
3486                revolved,
3487                line([2.0, 0.0, 0.0], [2.0, 0.0, 6.0]),
3488                [0.0, 0.0],
3489                [1.0, core::f64::consts::FRAC_PI_2],
3490                Point::new(2.0, 0.0, 0.0),
3491                Point::new(0.0, 2.0, 6.0),
3492            ),
3493        ];
3494        for (surface, curve, from, to, at_from, at_to) in cases {
3495            let deck = file(vec![
3496                (1, surface),
3497                (3, line([from[0], from[1], 0.0], [to[0], to[1], 0.0])),
3498                (5, on_surface(1)),
3499                (7, curve),
3500                (9, line([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
3501            ]);
3502            let mut reader = reader(&deck);
3503            let segments = reader.boundary_segments(5).unwrap();
3504            let [(lifted, (t0, t1))] = segments.as_slice() else {
3505                panic!("one segment");
3506            };
3507            let (a, b) = (
3508                lifted.point_at(*t0, T).unwrap(),
3509                lifted.point_at(*t1, T).unwrap(),
3510            );
3511            assert!(a.distance(at_from) < 1e-6, "{a:?} against {at_from:?}");
3512            assert!(b.distance(at_to) < 1e-6, "{b:?} against {at_to:?}");
3513        }
3514    }
3515
3516    /// A linear dimension in model space draws its leaders and witness
3517    /// lines, is named by its note's text, and states the value that text
3518    /// reads; a note a drawing owns stays on the sheet.
3519    #[test]
3520    fn a_model_space_dimension_reads_as_its_callout_and_value() {
3521        let note = |text: &str| {
3522            let mut e = entity(
3523                212,
3524                0,
3525                vec![
3526                    Value::Int(1),
3527                    Value::Int(5),
3528                    Value::Real(10.0),
3529                    Value::Real(3.0),
3530                    Value::Int(1),
3531                    Value::Real(0.0),
3532                    Value::Real(0.0),
3533                    Value::Int(0),
3534                    Value::Int(0),
3535                    Value::Real(10.0),
3536                    Value::Real(12.0),
3537                    Value::Real(0.0),
3538                    Value::Text(text.to_owned()),
3539                ],
3540            );
3541            e.status = 10_000;
3542            e
3543        };
3544        let leader = |head: [f64; 2], tail: [f64; 2]| {
3545            let mut e = entity(
3546                214,
3547                1,
3548                reals(&[1.0, 1.0, 0.5, 0.0, head[0], head[1], tail[0], tail[1]]),
3549            );
3550            e.status = 10_000;
3551            e
3552        };
3553        let witness = |a: [f64; 2], b: [f64; 2]| {
3554            let mut e = entity(106, 40, reals(&[1.0, 2.0, 0.0, a[0], a[1], b[0], b[1]]));
3555            e.status = 10_000;
3556            e
3557        };
3558        let dimension = entity(
3559            216,
3560            0,
3561            vec![
3562                Value::Int(3),
3563                Value::Int(5),
3564                Value::Int(7),
3565                Value::Int(9),
3566                Value::Int(11),
3567            ],
3568        );
3569        let mut sheet_note = note("on the sheet");
3570        sheet_note.status = 0;
3571        let drawing = entity(404, 0, vec![Value::Int(0), Value::Int(1), Value::Int(13)]);
3572        let deck = file(vec![
3573            (1, dimension),
3574            (3, note("2X 25.40")),
3575            (5, leader([0.0, 11.0], [8.0, 11.0])),
3576            (7, leader([25.4, 11.0], [17.0, 11.0])),
3577            (9, witness([0.0, 0.0], [0.0, 12.0])),
3578            (11, witness([25.4, 0.0], [25.4, 12.0])),
3579            (13, sheet_note),
3580            (15, drawing),
3581        ]);
3582        let mut reader = reader(&deck);
3583        let (callouts, dimensions) = reader.annotations();
3584        assert!(callouts.is_empty(), "the sheet's note stays on the sheet");
3585        let [(callout, dimension)] = dimensions.as_slice() else {
3586            panic!("one dimension, found {}", dimensions.len());
3587        };
3588        assert_eq!(callout.name, "2X 25.40");
3589        assert_eq!(
3590            callout.polylines.len(),
3591            4,
3592            "two leaders and two witness lines"
3593        );
3594        assert!(
3595            (dimension.values[0] - 25.4).abs() < 1e-12,
3596            "{:?}",
3597            dimension.values
3598        );
3599        assert!(dimension.location);
3600        assert!(!reader.visited.contains_key(&13));
3601    }
3602
3603    /// Two instances of one subfigure: a ten-square trimmed plane placed at
3604    /// x = 100, and at y = 50 twice the size. Each is the definition's face
3605    /// under its own placement, and the definition is built once.
3606    #[test]
3607    fn subfigure_instances_place_their_definition() {
3608        let dependent = |mut e: Entity| {
3609            e.status = 10_000;
3610            e
3611        };
3612        let square = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
3613        let mut entities = vec![(1, dependent(entity(108, 0, reals(&[0.0, 0.0, 1.0, 0.0]))))];
3614        let mut sides = Vec::new();
3615        for i in 0..4 {
3616            let (a, b) = (square[i], square[(i + 1) % 4]);
3617            let de = 3 + 2 * i64::try_from(i).unwrap();
3618            entities.push((de, dependent(line([a[0], a[1], 0.0], [b[0], b[1], 0.0]))));
3619            sides.push(Value::Int(de));
3620        }
3621        let mut composite = vec![Value::Int(4)];
3622        composite.extend(sides);
3623        entities.push((11, dependent(entity(102, 0, composite))));
3624        entities.push((
3625            13,
3626            dependent(entity(
3627                144,
3628                0,
3629                vec![Value::Int(1), Value::Int(1), Value::Int(0), Value::Int(11)],
3630            )),
3631        ));
3632        entities.push((
3633            15,
3634            entity(
3635                308,
3636                0,
3637                vec![
3638                    Value::Int(0),
3639                    Value::Text("tile".to_owned()),
3640                    Value::Int(1),
3641                    Value::Int(13),
3642                ],
3643            ),
3644        ));
3645        entities.push((
3646            17,
3647            entity(
3648                408,
3649                0,
3650                vec![
3651                    Value::Int(15),
3652                    Value::Real(100.0),
3653                    Value::Real(0.0),
3654                    Value::Real(0.0),
3655                    Value::Real(1.0),
3656                ],
3657            ),
3658        ));
3659        entities.push((
3660            19,
3661            entity(
3662                408,
3663                0,
3664                vec![
3665                    Value::Int(15),
3666                    Value::Real(0.0),
3667                    Value::Real(50.0),
3668                    Value::Real(0.0),
3669                    Value::Real(2.0),
3670                ],
3671            ),
3672        ));
3673        let deck = file(entities);
3674        let mut reader = reader(&deck);
3675        let (solids, sheets, _) = reader.subfigure_instances(T).unwrap();
3676        assert!(solids.is_empty());
3677        assert_eq!(sheets.len(), 2);
3678        let deflection = ogeom_mesh::Deflection::default();
3679        let mut measured: Vec<(f64, Point)> = sheets
3680            .iter()
3681            .map(|sheet| {
3682                let props =
3683                    ogeom_algo::surface_properties(&reader.model, sheet, deflection, T).unwrap();
3684                (props.mass, props.centre)
3685            })
3686            .collect();
3687        measured.sort_by(|a, b| a.0.total_cmp(&b.0));
3688        assert!((measured[0].0 - 100.0).abs() < 1e-9, "{measured:?}");
3689        assert!(measured[0].1.distance(Point::new(105.0, 5.0, 0.0)) < 1e-9);
3690        assert!((measured[1].0 - 400.0).abs() < 1e-9, "{measured:?}");
3691        assert!(measured[1].1.distance(Point::new(10.0, 60.0, 0.0)) < 1e-9);
3692        // One definition, one face node under two placements.
3693        let face_of = |shape: &Shape| {
3694            ogeom_topo::explore(
3695                &reader.model,
3696                shape,
3697                ogeom_topo::Filter::OfType(ogeom_topo::ShapeType::Face),
3698            )
3699            .unwrap()
3700            .remove(0)
3701        };
3702        assert_eq!(face_of(&sheets[0]).node(), face_of(&sheets[1]).node());
3703    }
3704
3705    /// Levels and groups are layers: each face on its level's layer, and a
3706    /// group's members on the group's own.
3707    #[test]
3708    fn levels_and_groups_read_as_layers() {
3709        let square = |z: f64, base: i64| -> Vec<(i64, Entity)> {
3710            let dependent = |mut e: Entity| {
3711                e.status = 10_000;
3712                e
3713            };
3714            let corners = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
3715            let mut out = vec![(base, dependent(entity(108, 0, reals(&[0.0, 0.0, 1.0, z]))))];
3716            let mut sides = vec![Value::Int(4)];
3717            for i in 0..4 {
3718                let (a, b) = (corners[i], corners[(i + 1) % 4]);
3719                let de = base + 2 + 2 * i64::try_from(i).unwrap();
3720                out.push((de, dependent(line([a[0], a[1], z], [b[0], b[1], z]))));
3721                sides.push(Value::Int(de));
3722            }
3723            out.push((base + 10, dependent(entity(102, 0, sides))));
3724            out
3725        };
3726        let mut entities = square(0.0, 1);
3727        entities.extend(square(5.0, 21));
3728        let mut low = entity(
3729            144,
3730            0,
3731            vec![Value::Int(1), Value::Int(1), Value::Int(0), Value::Int(11)],
3732        );
3733        low.level = 3;
3734        let mut high = entity(
3735            144,
3736            0,
3737            vec![Value::Int(21), Value::Int(1), Value::Int(0), Value::Int(31)],
3738        );
3739        high.level = 7;
3740        entities.push((41, low));
3741        entities.push((43, high));
3742        let mut group = entity(402, 7, vec![Value::Int(1), Value::Int(43)]);
3743        group.label = "LID".to_owned();
3744        entities.push((45, group));
3745        let deck = file(entities);
3746        let mut reader = reader(&deck);
3747        let low_face = reader.face(41).unwrap();
3748        let high_face = reader.face(43).unwrap();
3749        let built = vec![(41, low_face.clone()), (43, high_face.clone())];
3750        let mut document = ogeom_doc::Document::over(std::mem::take(&mut reader.model));
3751        let mut report = IgesReport::default();
3752        layers(&deck, &mut document, &built, &mut report);
3753        let names_of = |shape: &Shape| -> Vec<String> {
3754            let mut names: Vec<String> = document
3755                .layers_of(shape)
3756                .iter()
3757                .map(|id| document.layer(*id).unwrap().name.clone())
3758                .collect();
3759            names.sort();
3760            names
3761        };
3762        assert_eq!(names_of(&low_face), vec!["level 3".to_owned()]);
3763        assert_eq!(
3764            names_of(&high_face),
3765            vec!["LID".to_owned(), "level 7".to_owned()]
3766        );
3767    }
3768
3769    /// Constructive solids against their closed forms: a block drilled by a
3770    /// cylinder through a boolean tree, a wedge, a cone frustum, an
3771    /// ellipsoid, a half-turn of revolution, an extruded square, and an
3772    /// assembly placing one block twice.
3773    #[test]
3774    fn constructive_solids_read_as_their_volumes() {
3775        let pi = core::f64::consts::PI;
3776        let square = |base: i64, corners: [[f64; 3]; 4]| -> Vec<(i64, Entity)> {
3777            let mut out = Vec::new();
3778            let mut sides = vec![Value::Int(4)];
3779            for i in 0..4 {
3780                let de = base + 2 * i64::try_from(i).unwrap();
3781                out.push((de, line(corners[i], corners[(i + 1) % 4])));
3782                sides.push(Value::Int(de));
3783            }
3784            out.push((base + 8, entity(102, 0, sides)));
3785            out
3786        };
3787        let mut entities = vec![
3788            // Block 20³ at the origin.
3789            (
3790                1,
3791                entity(
3792                    150,
3793                    0,
3794                    reals(&[
3795                        20.0, 20.0, 20.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
3796                    ]),
3797                ),
3798            ),
3799            // Cylinder r 5, h 40, up the block's middle from below.
3800            (
3801                3,
3802                entity(
3803                    154,
3804                    0,
3805                    reals(&[40.0, 5.0, 10.0, 10.0, -10.0, 0.0, 0.0, 1.0]),
3806                ),
3807            ),
3808            (
3809                5,
3810                entity(
3811                    180,
3812                    0,
3813                    vec![Value::Int(3), Value::Int(-1), Value::Int(-3), Value::Int(3)],
3814                ),
3815            ),
3816            // Wedge 10 × 5 × 4, narrowing to 6 along x at y = 5.
3817            (
3818                7,
3819                entity(
3820                    152,
3821                    0,
3822                    reals(&[
3823                        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,
3824                    ]),
3825                ),
3826            ),
3827            // Frustum h 10 from r 5 to r 2.
3828            (
3829                9,
3830                entity(
3831                    156,
3832                    0,
3833                    reals(&[10.0, 5.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]),
3834                ),
3835            ),
3836            // Ellipsoid 3 × 2 × 1.
3837            (
3838                11,
3839                entity(
3840                    168,
3841                    0,
3842                    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]),
3843                ),
3844            ),
3845            // Half a turn of the rectangle x 2..4, z 0..3 about z.
3846            (
3847                13,
3848                entity(
3849                    162,
3850                    0,
3851                    vec![
3852                        Value::Int(29),
3853                        Value::Real(0.5),
3854                        Value::Real(0.0),
3855                        Value::Real(0.0),
3856                        Value::Real(0.0),
3857                        Value::Real(0.0),
3858                        Value::Real(0.0),
3859                        Value::Real(1.0),
3860                    ],
3861                ),
3862            ),
3863            // The square 0..2 in xy extruded 5 up z.
3864            (
3865                15,
3866                entity(
3867                    164,
3868                    0,
3869                    vec![
3870                        Value::Int(49),
3871                        Value::Real(5.0),
3872                        Value::Real(0.0),
3873                        Value::Real(0.0),
3874                        Value::Real(1.0),
3875                    ],
3876                ),
3877            ),
3878            // The block twice: as it stands, and moved 100 along x.
3879            (
3880                17,
3881                entity(
3882                    124,
3883                    0,
3884                    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]),
3885                ),
3886            ),
3887            (
3888                19,
3889                entity(
3890                    184,
3891                    0,
3892                    vec![
3893                        Value::Int(2),
3894                        Value::Int(1),
3895                        Value::Int(1),
3896                        Value::Int(0),
3897                        Value::Int(17),
3898                    ],
3899                ),
3900            ),
3901        ];
3902        entities.extend(square(
3903            21,
3904            [
3905                [2.0, 0.0, 0.0],
3906                [4.0, 0.0, 0.0],
3907                [4.0, 0.0, 3.0],
3908                [2.0, 0.0, 3.0],
3909            ],
3910        ));
3911        entities.extend(square(
3912            41,
3913            [
3914                [0.0, 0.0, 0.0],
3915                [2.0, 0.0, 0.0],
3916                [2.0, 2.0, 0.0],
3917                [0.0, 2.0, 0.0],
3918            ],
3919        ));
3920        let deck = file(entities);
3921        let mut reader = reader(&deck);
3922        let volume = |reader: &Reader<'_>, shape: &Shape| {
3923            ogeom_algo::volume_properties(
3924                &reader.model,
3925                shape,
3926                ogeom_mesh::Deflection::with_chord(1e-3).unwrap(),
3927                T,
3928            )
3929            .unwrap()
3930            .mass
3931        };
3932        for (de, want, within) in [
3933            (5, 8000.0 - pi * 25.0 * 20.0, 1e-6),
3934            (7, (10.0 + 6.0) / 2.0 * 5.0 * 4.0, 1e-9),
3935            (9, pi * 10.0 / 3.0 * (25.0 + 10.0 + 4.0), 1e-6),
3936            // Exact on its equation (below); the mesh measuring the volume
3937            // falls short by the chord over the area, a part in a thousand.
3938            (11, 4.0 / 3.0 * pi * 6.0, 2e-3),
3939            (13, 0.5 * pi * (16.0 - 4.0) * 3.0, 1e-6),
3940            (15, 20.0, 1e-9),
3941        ] {
3942            let solids = reader.csg_solids(de).unwrap();
3943            let [solid] = solids.as_slice() else {
3944                panic!("D{de} is one solid");
3945            };
3946            let got = volume(&reader, solid);
3947            assert!(
3948                (got - want).abs() <= within * want.max(1.0),
3949                "D{de}: {got} against {want}"
3950            );
3951        }
3952        // The ellipsoid's surface on its own equation, at points spread
3953        // over every face.
3954        let ellipsoid = reader.csg_solids(11).unwrap().remove(0);
3955        for face in ogeom_topo::explore(
3956            &reader.model,
3957            &ellipsoid,
3958            ogeom_topo::Filter::OfType(ogeom_topo::ShapeType::Face),
3959        )
3960        .unwrap()
3961        {
3962            let data = reader
3963                .model
3964                .node(&face)
3965                .unwrap()
3966                .data()
3967                .as_face()
3968                .unwrap()
3969                .clone();
3970            let surface = reader
3971                .model
3972                .geometry()
3973                .surface(data.surface)
3974                .unwrap()
3975                .clone();
3976            let ((u0, u1), (v0, v1)) = ogeom_geom::Surface::domain(&surface);
3977            for i in 0..=6 {
3978                for j in 0..=6 {
3979                    let (u, v) = (
3980                        u0 + (u1 - u0) * f64::from(i) / 6.0,
3981                        v0 + (v1 - v0) * f64::from(j) / 6.0,
3982                    );
3983                    let p = ogeom_geom::Surface::point_at(&surface, u, v, T).unwrap();
3984                    let residual = p.x * p.x / 9.0 + p.y * p.y / 4.0 + p.z * p.z - 1.0;
3985                    assert!(residual.abs() < 1e-9, "off the ellipsoid: {p:?}");
3986                }
3987            }
3988        }
3989        let placed = reader.csg_solids(19).unwrap();
3990        assert_eq!(placed.len(), 2);
3991        let centres: Vec<Point> = placed
3992            .iter()
3993            .map(|s| {
3994                ogeom_algo::volume_properties(
3995                    &reader.model,
3996                    s,
3997                    ogeom_mesh::Deflection::default(),
3998                    T,
3999                )
4000                .unwrap()
4001                .centre
4002            })
4003            .collect();
4004        assert!(
4005            centres
4006                .iter()
4007                .any(|c| c.distance(Point::new(10.0, 10.0, 10.0)) < 1e-9)
4008        );
4009        assert!(
4010            centres
4011                .iter()
4012                .any(|c| c.distance(Point::new(110.0, 10.0, 10.0)) < 1e-9)
4013        );
4014    }
4015}