Skip to main content

brep_kernel/io/iges/
import.rs

1//! IGES trimmed-NURBS-surface → kernel `BrepSolid` import.
2//!
3//! Reads every **144** Trimmed Surface as one kernel face: the **128** surface,
4//! and one loop per **142** Curve-on-Surface. For each loop coedge the primary
5//! path reads the parameter-space boundary (`BPTR`) directly as the coedge
6//! pcurve and the model-space boundary (`CPTR`) as the 3D edge curve — a
7//! lossless round-trip of our own exports. When those disagree (a foreign file
8//! whose parameter and model curves don't correspond one-to-one) it falls back
9//! to deriving the pcurve by projecting the 3D curve onto the carrier surface
10//! with [`crate::build_pcurve_on_surface_range`], mirroring the STEP importer.
11//!
12//! The reconstructed faces form a face soup that [`crate::sew_solid`] pairs into
13//! a coherently oriented, outward-facing solid; unsupported entities (bare
14//! untrimmed surfaces, MSBO 186) yield a precise error rather than a panic.
15
16use crate::topology::{
17    BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, ShellRecord, VertexRecord,
18};
19use crate::{build_pcurve_on_surface_range, sew_solid, NurbsCurve, NurbsSurface, Vec3};
20
21use super::entities::{
22    composite_members, curve_from_126, curve_on_surface_from_142, surface_from_128,
23    trimmed_surface_from_144,
24};
25use super::reader::{parse_iges, IgesFile};
26
27/// Parse an IGES document and reconstruct one or more `BrepSolid`s.
28pub fn import_iges(text: &str) -> Result<Vec<BrepSolid>, String> {
29    let file = parse_iges(text)?;
30    let scale = file.global.length_scale_mm()?;
31
32    let mut soup = SoupBuilder::new();
33    let mut faces: Vec<FaceRecord> = Vec::new();
34    for entity in file.entities_of_type(144) {
35        let ts = trimmed_surface_from_144(entity)?;
36        let face = soup.build_face(&file, &ts, scale)?;
37        faces.push(face);
38    }
39
40    if faces.is_empty() {
41        // Precise diagnostics for the common unsupported representations.
42        if file.entities_of_type(186).next().is_some() {
43            return Err(
44                "iges_import: file uses Manifold Solid B-Rep Object (entity 186), which is not yet supported"
45                    .into(),
46            );
47        }
48        if file.entities_of_type(128).next().is_some() {
49            return Err(
50                "iges_import: file has untrimmed B-spline surfaces (entity 128) but no trimmed surfaces (entity 144) to form faces"
51                    .into(),
52            );
53        }
54        return Err("iges_import: no trimmed NURBS surfaces (entity 144) found".into());
55    }
56
57    // Characteristic size for sew tolerance.
58    let diag = soup.bounding_diagonal();
59    let sew_tol = (1e-6 * diag).max(1e-9);
60
61    // IGES has no body grouping, so the file is a flat face set. Partition it
62    // into connected components (faces sharing a welded vertex belong to the
63    // same body) and sew each into its own solid — matching the STEP importer's
64    // one-solid-per-body contract. A single body is one component, so this is
65    // behaviour-identical to a single sew for the common case.
66    let mut next_id = soup.next_id;
67    let SoupBuilder { vertices, edges, .. } = soup;
68    let components = partition_faces_into_bodies(&faces, &edges);
69
70    let mut solids = Vec::with_capacity(components.len());
71    for face_indices in components {
72        let body_faces: Vec<FaceRecord> =
73            face_indices.iter().map(|&i| faces[i].clone()).collect();
74        let mut edge_ids: rustc_hash::FxHashSet<u64> = Default::default();
75        for face in &body_faces {
76            for loop_record in &face.loops {
77                for coedge in &loop_record.coedges {
78                    edge_ids.insert(coedge.edge_id);
79                }
80            }
81        }
82        let body_edges: Vec<EdgeRecord> =
83            edges.iter().filter(|e| edge_ids.contains(&e.id)).cloned().collect();
84        let mut vertex_ids: rustc_hash::FxHashSet<u64> = Default::default();
85        for edge in &body_edges {
86            vertex_ids.insert(edge.start_vertex_id);
87            vertex_ids.insert(edge.end_vertex_id);
88        }
89        let body_vertices: Vec<VertexRecord> =
90            vertices.iter().filter(|v| vertex_ids.contains(&v.id)).cloned().collect();
91
92        let shell_id = next_id;
93        let solid_id = next_id + 1;
94        next_id += 2;
95        let solid = BrepSolid {
96            mass_properties_cache: Default::default(),
97            id: solid_id,
98            vertices: body_vertices,
99            edges: body_edges,
100            shells: vec![ShellRecord {
101                id: shell_id,
102                faces: body_faces,
103            }],
104            genus: 0,
105            kind: crate::BodyKind::Solid,
106        };
107        let (sewn, _report) = sew_solid(&solid, sew_tol)?;
108        let issues = sewn.validate();
109        if !issues.is_empty() {
110            return Err(format!(
111                "iges_import: reconstructed solid failed validation: {issues:?}"
112            ));
113        }
114        solids.push(sewn);
115    }
116    Ok(solids)
117}
118
119/// Group faces into bodies by shared welded vertices (union-find). Each returned
120/// vector holds the indices (into `faces`) of one connected component.
121fn partition_faces_into_bodies(faces: &[FaceRecord], edges: &[EdgeRecord]) -> Vec<Vec<usize>> {
122    use rustc_hash::FxHashMap as HashMap;
123
124    let edge_vertices: HashMap<u64, (u64, u64)> = edges
125        .iter()
126        .map(|e| (e.id, (e.start_vertex_id, e.end_vertex_id)))
127        .collect();
128
129    let mut parent: Vec<usize> = (0..faces.len()).collect();
130    fn find(parent: &mut Vec<usize>, mut x: usize) -> usize {
131        while parent[x] != x {
132            parent[x] = parent[parent[x]];
133            x = parent[x];
134        }
135        x
136    }
137
138    // First face that referenced a given vertex; union subsequent faces to it.
139    let mut vertex_owner: HashMap<u64, usize> = HashMap::default();
140    for (face_index, face) in faces.iter().enumerate() {
141        for loop_record in &face.loops {
142            for coedge in &loop_record.coedges {
143                if let Some(&(a, b)) = edge_vertices.get(&coedge.edge_id) {
144                    for vertex in [a, b] {
145                        match vertex_owner.get(&vertex) {
146                            Some(&owner) => {
147                                let ra = find(&mut parent, owner);
148                                let rb = find(&mut parent, face_index);
149                                parent[ra] = rb;
150                            }
151                            None => {
152                                vertex_owner.insert(vertex, face_index);
153                            }
154                        }
155                    }
156                }
157            }
158        }
159    }
160
161    let mut groups: HashMap<usize, Vec<usize>> = HashMap::default();
162    for face_index in 0..faces.len() {
163        let root = find(&mut parent, face_index);
164        groups.entry(root).or_default().push(face_index);
165    }
166    let mut components: Vec<Vec<usize>> = groups.into_values().collect();
167    // Deterministic order: by smallest face index in each component.
168    components.sort_by_key(|c| c.iter().copied().min().unwrap_or(0));
169    components
170}
171
172/// Accumulates the shared vertex/edge pools while building faces, welding
173/// coincident vertices so loops close and adjacent faces share endpoints.
174struct SoupBuilder {
175    next_id: u64,
176    vertices: Vec<VertexRecord>,
177    edges: Vec<EdgeRecord>,
178}
179
180impl SoupBuilder {
181    fn new() -> Self {
182        Self {
183            next_id: 1,
184            vertices: Vec::new(),
185            edges: Vec::new(),
186        }
187    }
188
189    fn fresh(&mut self) -> u64 {
190        let id = self.next_id;
191        self.next_id += 1;
192        id
193    }
194
195    fn weld_vertex(&mut self, point: Vec3) -> u64 {
196        let tol = 1e-7 * (1.0 + point.length());
197        if let Some(existing) = self
198            .vertices
199            .iter()
200            .find(|v| v.point.sub(point).length() <= tol)
201        {
202            return existing.id;
203        }
204        let id = self.fresh();
205        self.vertices.push(VertexRecord { id, point });
206        id
207    }
208
209    fn bounding_diagonal(&self) -> f64 {
210        if self.vertices.is_empty() {
211            return 1.0;
212        }
213        let mut lo = self.vertices[0].point;
214        let mut hi = self.vertices[0].point;
215        for v in &self.vertices {
216            lo.x = lo.x.min(v.point.x);
217            lo.y = lo.y.min(v.point.y);
218            lo.z = lo.z.min(v.point.z);
219            hi.x = hi.x.max(v.point.x);
220            hi.y = hi.y.max(v.point.y);
221            hi.z = hi.z.max(v.point.z);
222        }
223        hi.sub(lo).length().max(1.0)
224    }
225
226    fn build_face(
227        &mut self,
228        file: &IgesFile,
229        ts: &super::entities::TrimmedSurface,
230        scale: f64,
231    ) -> Result<FaceRecord, String> {
232        let surface_entity = file.entity(ts.surface)?;
233        if surface_entity.de.entity_type != 128 {
234            return Err(format!(
235                "iges_import: trimmed surface points to entity type {} (expected 128)",
236                surface_entity.de.entity_type
237            ));
238        }
239        let surface = surface_from_128(surface_entity, scale)?;
240
241        let mut loops: Vec<LoopRecord> = Vec::new();
242        // Outer boundary first (kernel convention), then holes. `N1=0` means the
243        // outer boundary is the surface's natural parameter domain (no explicit
244        // outer loop); reconstructing that would need a synthetic domain-edge
245        // loop, so it is refused loudly rather than yielding a hole-only face.
246        if ts.outer.is_none() {
247            return Err(
248                "iges_import: trimmed surface with an implicit (natural-domain) outer boundary (144 N1=0) is not supported"
249                    .into(),
250            );
251        }
252        let mut boundary_ptrs: Vec<i64> = Vec::new();
253        boundary_ptrs.extend(ts.outer);
254        boundary_ptrs.extend(ts.inner.iter().copied());
255        for ptr in boundary_ptrs {
256            loops.push(self.build_loop(file, ptr, ts.surface, &surface, scale)?);
257        }
258
259        // Orientation is encoded by `same_sense` together with loop winding:
260        // flipping a face toggles `same_sense` and reverses its loops, so a
261        // valid outward face has `same_sense == (outer-loop parameter-space
262        // winding is CCW)`. The pcurves are preserved faithfully, so recover
263        // the original sense directly rather than leaving it to sew heuristics
264        // (which mis-resolve concave inner-loop walls).
265        let same_sense = loops
266            .first()
267            .map(|outer| loop_param_signed_area(outer) >= 0.0)
268            .unwrap_or(true);
269
270        let id = self.fresh();
271        Ok(FaceRecord {
272            id,
273            surface,
274            same_sense,
275            loops,
276            name: None,
277        })
278    }
279
280    fn build_loop(
281        &mut self,
282        file: &IgesFile,
283        boundary_ptr: i64,
284        surface_ptr: i64,
285        surface: &NurbsSurface,
286        scale: f64,
287    ) -> Result<LoopRecord, String> {
288        let cos_entity = file.entity(boundary_ptr)?;
289        if cos_entity.de.entity_type != 142 {
290            return Err(format!(
291                "iges_import: boundary points to entity type {} (expected 142 curve-on-surface)",
292                cos_entity.de.entity_type
293            ));
294        }
295        let cos = curve_on_surface_from_142(cos_entity)?;
296        if cos.surface != surface_ptr {
297            return Err(format!(
298                "iges_import: curve-on-surface references surface DE {} but its trimmed surface is DE {surface_ptr}",
299                cos.surface
300            ));
301        }
302        let model_curves = resolve_curve_list(file, cos.model_curve, Some(scale))?;
303        let param_curves = resolve_curve_list(file, cos.param_curve, None)?;
304
305        let use_faithful =
306            !param_curves.is_empty() && param_curves.len() == model_curves.len() && {
307                // Sanity: parameter and model boundary must agree at a midpoint.
308                faithful_pcurves_agree(surface, &model_curves[0], &param_curves[0])
309            };
310
311        let mut coedges: Vec<CoedgeRecord> = Vec::new();
312        for (index, model_curve) in model_curves.iter().enumerate() {
313            let pcurve = if use_faithful {
314                param_curves[index].clone()
315            } else {
316                derive_pcurve(surface, model_curve)?
317            };
318            coedges.push(self.build_coedge(model_curve, pcurve)?);
319        }
320        let id = self.fresh();
321        Ok(LoopRecord { id, coedges })
322    }
323
324    fn build_coedge(
325        &mut self,
326        model_curve: &NurbsCurve,
327        pcurve: NurbsCurve,
328    ) -> Result<CoedgeRecord, String> {
329        let [t0, t1] = model_curve.domain()?;
330        let p_start = model_curve.evaluate(t0)?;
331        let p_end = model_curve.evaluate(t1)?;
332        let start_vertex_id = self.weld_vertex(p_start);
333        let end_vertex_id = self.weld_vertex(p_end);
334        let degenerate = start_vertex_id == end_vertex_id && curve_collapsed(model_curve, p_start);
335        let edge_id = self.fresh();
336        self.edges.push(EdgeRecord {
337            id: edge_id,
338            curve: model_curve.clone(),
339            t0,
340            t1,
341            start_vertex_id,
342            end_vertex_id,
343            degenerate,
344            name: None,
345        });
346        let coedge_id = self.fresh();
347        Ok(CoedgeRecord {
348            id: coedge_id,
349            edge_id,
350            forward: true,
351            pcurve,
352        })
353    }
354}
355
356/// Resolve a boundary pointer (a 102 composite or a bare 126) into its ordered
357/// list of NURBS curves. `coord_scale` is `Some` for 3D model curves, `None`
358/// for parameter-space curves.
359fn resolve_curve_list(
360    file: &IgesFile,
361    ptr: i64,
362    coord_scale: Option<f64>,
363) -> Result<Vec<NurbsCurve>, String> {
364    let entity = file.entity(ptr)?;
365    match entity.de.entity_type {
366        102 => {
367            let members = composite_members(entity)?;
368            let mut curves = Vec::with_capacity(members.len());
369            for m in members {
370                let member = file.entity(m)?;
371                if member.de.entity_type != 126 {
372                    return Err(format!(
373                        "iges_import: composite member is entity type {} (expected 126)",
374                        member.de.entity_type
375                    ));
376                }
377                curves.push(curve_from_126(member, coord_scale)?);
378            }
379            Ok(curves)
380        }
381        126 => Ok(vec![curve_from_126(entity, coord_scale)?]),
382        other => Err(format!(
383            "iges_import: boundary curve is entity type {other} (expected 102 or 126)"
384        )),
385    }
386}
387
388/// Check that a parameter-space curve evaluated on the surface reproduces the
389/// model-space curve at their common midpoint.
390fn faithful_pcurves_agree(surface: &NurbsSurface, model: &NurbsCurve, param: &NurbsCurve) -> bool {
391    let evaluate = || -> Result<bool, String> {
392        let [pt0, pt1] = param.domain()?;
393        let mid = 0.5 * (pt0 + pt1);
394        let uv = param.evaluate(mid)?;
395        let on_surface = surface.evaluate(uv.x, uv.y)?;
396        let [mt0, mt1] = model.domain()?;
397        let model_mid = model.evaluate(0.5 * (mt0 + mt1))?;
398        let tol = 1e-4 * (1.0 + model_mid.length());
399        Ok(on_surface.sub(model_mid).length() <= tol)
400    };
401    evaluate().unwrap_or(false)
402}
403
404/// Derive a pcurve for a foreign file by projecting the 3D curve onto the
405/// carrier surface.
406fn derive_pcurve(surface: &NurbsSurface, model: &NurbsCurve) -> Result<NurbsCurve, String> {
407    let [t0, t1] = model.domain()?;
408    let magnitude = model.evaluate(0.5 * (t0 + t1)).map(|p| p.length()).unwrap_or(1.0);
409    let tol = 1e-6 * (1.0 + magnitude);
410    build_pcurve_on_surface_range(surface, model, t0, t1, true, tol)
411}
412
413/// Signed area of a loop in surface parameter space (shoelace over sampled
414/// pcurve points). Positive = counter-clockwise in `(u, v)`.
415fn loop_param_signed_area(loop_record: &LoopRecord) -> f64 {
416    let mut pts: Vec<(f64, f64)> = Vec::new();
417    for coedge in &loop_record.coedges {
418        let curve = &coedge.pcurve;
419        let [t0, t1] = match curve.domain() {
420            Ok(d) => d,
421            Err(_) => continue,
422        };
423        // Sample the interior of each pcurve; endpoints are shared with
424        // neighbours so the concatenation forms the closed loop polygon.
425        const SAMPLES: usize = 8;
426        for i in 0..SAMPLES {
427            let t = t0 + (t1 - t0) * i as f64 / SAMPLES as f64;
428            if let Ok(p) = curve.evaluate(t) {
429                pts.push((p.x, p.y));
430            }
431        }
432    }
433    if pts.len() < 3 {
434        return 0.0;
435    }
436    let mut area = 0.0;
437    for i in 0..pts.len() {
438        let (x0, y0) = pts[i];
439        let (x1, y1) = pts[(i + 1) % pts.len()];
440        area += x0 * y1 - x1 * y0;
441    }
442    0.5 * area
443}
444
445/// Multi-station collapse test: true when the curve stays within tolerance of a
446/// single point (a pole/degenerate edge), distinguishing it from a closed loop.
447fn curve_collapsed(curve: &NurbsCurve, at: Vec3) -> bool {
448    let tol = 1e-7 * (1.0 + at.length());
449    let [t0, t1] = match curve.domain() {
450        Ok(d) => d,
451        Err(_) => return false,
452    };
453    for index in 1..8 {
454        let t = t0 + (t1 - t0) * index as f64 / 8.0;
455        match curve.evaluate(t) {
456            Ok(p) => {
457                if p.sub(at).length() > tol {
458                    return false;
459                }
460            }
461            Err(_) => return false,
462        }
463    }
464    true
465}