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            id: solid_id,
97            vertices: body_vertices,
98            edges: body_edges,
99            shells: vec![ShellRecord {
100                id: shell_id,
101                faces: body_faces,
102            }],
103            genus: 0,
104        };
105        let (sewn, _report) = sew_solid(&solid, sew_tol)?;
106        let issues = sewn.validate();
107        if !issues.is_empty() {
108            return Err(format!(
109                "iges_import: reconstructed solid failed validation: {issues:?}"
110            ));
111        }
112        solids.push(sewn);
113    }
114    Ok(solids)
115}
116
117/// Group faces into bodies by shared welded vertices (union-find). Each returned
118/// vector holds the indices (into `faces`) of one connected component.
119fn partition_faces_into_bodies(faces: &[FaceRecord], edges: &[EdgeRecord]) -> Vec<Vec<usize>> {
120    use rustc_hash::FxHashMap as HashMap;
121
122    let edge_vertices: HashMap<u64, (u64, u64)> = edges
123        .iter()
124        .map(|e| (e.id, (e.start_vertex_id, e.end_vertex_id)))
125        .collect();
126
127    let mut parent: Vec<usize> = (0..faces.len()).collect();
128    fn find(parent: &mut Vec<usize>, mut x: usize) -> usize {
129        while parent[x] != x {
130            parent[x] = parent[parent[x]];
131            x = parent[x];
132        }
133        x
134    }
135
136    // First face that referenced a given vertex; union subsequent faces to it.
137    let mut vertex_owner: HashMap<u64, usize> = HashMap::default();
138    for (face_index, face) in faces.iter().enumerate() {
139        for loop_record in &face.loops {
140            for coedge in &loop_record.coedges {
141                if let Some(&(a, b)) = edge_vertices.get(&coedge.edge_id) {
142                    for vertex in [a, b] {
143                        match vertex_owner.get(&vertex) {
144                            Some(&owner) => {
145                                let ra = find(&mut parent, owner);
146                                let rb = find(&mut parent, face_index);
147                                parent[ra] = rb;
148                            }
149                            None => {
150                                vertex_owner.insert(vertex, face_index);
151                            }
152                        }
153                    }
154                }
155            }
156        }
157    }
158
159    let mut groups: HashMap<usize, Vec<usize>> = HashMap::default();
160    for face_index in 0..faces.len() {
161        let root = find(&mut parent, face_index);
162        groups.entry(root).or_default().push(face_index);
163    }
164    let mut components: Vec<Vec<usize>> = groups.into_values().collect();
165    // Deterministic order: by smallest face index in each component.
166    components.sort_by_key(|c| c.iter().copied().min().unwrap_or(0));
167    components
168}
169
170/// Accumulates the shared vertex/edge pools while building faces, welding
171/// coincident vertices so loops close and adjacent faces share endpoints.
172struct SoupBuilder {
173    next_id: u64,
174    vertices: Vec<VertexRecord>,
175    edges: Vec<EdgeRecord>,
176}
177
178impl SoupBuilder {
179    fn new() -> Self {
180        Self {
181            next_id: 1,
182            vertices: Vec::new(),
183            edges: Vec::new(),
184        }
185    }
186
187    fn fresh(&mut self) -> u64 {
188        let id = self.next_id;
189        self.next_id += 1;
190        id
191    }
192
193    fn weld_vertex(&mut self, point: Vec3) -> u64 {
194        let tol = 1e-7 * (1.0 + point.length());
195        if let Some(existing) = self
196            .vertices
197            .iter()
198            .find(|v| v.point.sub(point).length() <= tol)
199        {
200            return existing.id;
201        }
202        let id = self.fresh();
203        self.vertices.push(VertexRecord { id, point });
204        id
205    }
206
207    fn bounding_diagonal(&self) -> f64 {
208        if self.vertices.is_empty() {
209            return 1.0;
210        }
211        let mut lo = self.vertices[0].point;
212        let mut hi = self.vertices[0].point;
213        for v in &self.vertices {
214            lo.x = lo.x.min(v.point.x);
215            lo.y = lo.y.min(v.point.y);
216            lo.z = lo.z.min(v.point.z);
217            hi.x = hi.x.max(v.point.x);
218            hi.y = hi.y.max(v.point.y);
219            hi.z = hi.z.max(v.point.z);
220        }
221        hi.sub(lo).length().max(1.0)
222    }
223
224    fn build_face(
225        &mut self,
226        file: &IgesFile,
227        ts: &super::entities::TrimmedSurface,
228        scale: f64,
229    ) -> Result<FaceRecord, String> {
230        let surface_entity = file.entity(ts.surface)?;
231        if surface_entity.de.entity_type != 128 {
232            return Err(format!(
233                "iges_import: trimmed surface points to entity type {} (expected 128)",
234                surface_entity.de.entity_type
235            ));
236        }
237        let surface = surface_from_128(surface_entity, scale)?;
238
239        let mut loops: Vec<LoopRecord> = Vec::new();
240        // Outer boundary first (kernel convention), then holes. `N1=0` means the
241        // outer boundary is the surface's natural parameter domain (no explicit
242        // outer loop); reconstructing that would need a synthetic domain-edge
243        // loop, so it is refused loudly rather than yielding a hole-only face.
244        if ts.outer.is_none() {
245            return Err(
246                "iges_import: trimmed surface with an implicit (natural-domain) outer boundary (144 N1=0) is not supported"
247                    .into(),
248            );
249        }
250        let mut boundary_ptrs: Vec<i64> = Vec::new();
251        boundary_ptrs.extend(ts.outer);
252        boundary_ptrs.extend(ts.inner.iter().copied());
253        for ptr in boundary_ptrs {
254            loops.push(self.build_loop(file, ptr, ts.surface, &surface, scale)?);
255        }
256
257        // Orientation is encoded by `same_sense` together with loop winding:
258        // flipping a face toggles `same_sense` and reverses its loops, so a
259        // valid outward face has `same_sense == (outer-loop parameter-space
260        // winding is CCW)`. The pcurves are preserved faithfully, so recover
261        // the original sense directly rather than leaving it to sew heuristics
262        // (which mis-resolve concave inner-loop walls).
263        let same_sense = loops
264            .first()
265            .map(|outer| loop_param_signed_area(outer) >= 0.0)
266            .unwrap_or(true);
267
268        let id = self.fresh();
269        Ok(FaceRecord {
270            id,
271            surface,
272            same_sense,
273            loops,
274            name: None,
275        })
276    }
277
278    fn build_loop(
279        &mut self,
280        file: &IgesFile,
281        boundary_ptr: i64,
282        surface_ptr: i64,
283        surface: &NurbsSurface,
284        scale: f64,
285    ) -> Result<LoopRecord, String> {
286        let cos_entity = file.entity(boundary_ptr)?;
287        if cos_entity.de.entity_type != 142 {
288            return Err(format!(
289                "iges_import: boundary points to entity type {} (expected 142 curve-on-surface)",
290                cos_entity.de.entity_type
291            ));
292        }
293        let cos = curve_on_surface_from_142(cos_entity)?;
294        if cos.surface != surface_ptr {
295            return Err(format!(
296                "iges_import: curve-on-surface references surface DE {} but its trimmed surface is DE {surface_ptr}",
297                cos.surface
298            ));
299        }
300        let model_curves = resolve_curve_list(file, cos.model_curve, Some(scale))?;
301        let param_curves = resolve_curve_list(file, cos.param_curve, None)?;
302
303        let use_faithful =
304            !param_curves.is_empty() && param_curves.len() == model_curves.len() && {
305                // Sanity: parameter and model boundary must agree at a midpoint.
306                faithful_pcurves_agree(surface, &model_curves[0], &param_curves[0])
307            };
308
309        let mut coedges: Vec<CoedgeRecord> = Vec::new();
310        for (index, model_curve) in model_curves.iter().enumerate() {
311            let pcurve = if use_faithful {
312                param_curves[index].clone()
313            } else {
314                derive_pcurve(surface, model_curve)?
315            };
316            coedges.push(self.build_coedge(model_curve, pcurve)?);
317        }
318        let id = self.fresh();
319        Ok(LoopRecord { id, coedges })
320    }
321
322    fn build_coedge(
323        &mut self,
324        model_curve: &NurbsCurve,
325        pcurve: NurbsCurve,
326    ) -> Result<CoedgeRecord, String> {
327        let [t0, t1] = model_curve.domain()?;
328        let p_start = model_curve.evaluate(t0)?;
329        let p_end = model_curve.evaluate(t1)?;
330        let start_vertex_id = self.weld_vertex(p_start);
331        let end_vertex_id = self.weld_vertex(p_end);
332        let degenerate = start_vertex_id == end_vertex_id && curve_collapsed(model_curve, p_start);
333        let edge_id = self.fresh();
334        self.edges.push(EdgeRecord {
335            id: edge_id,
336            curve: model_curve.clone(),
337            t0,
338            t1,
339            start_vertex_id,
340            end_vertex_id,
341            degenerate,
342            name: None,
343        });
344        let coedge_id = self.fresh();
345        Ok(CoedgeRecord {
346            id: coedge_id,
347            edge_id,
348            forward: true,
349            pcurve,
350        })
351    }
352}
353
354/// Resolve a boundary pointer (a 102 composite or a bare 126) into its ordered
355/// list of NURBS curves. `coord_scale` is `Some` for 3D model curves, `None`
356/// for parameter-space curves.
357fn resolve_curve_list(
358    file: &IgesFile,
359    ptr: i64,
360    coord_scale: Option<f64>,
361) -> Result<Vec<NurbsCurve>, String> {
362    let entity = file.entity(ptr)?;
363    match entity.de.entity_type {
364        102 => {
365            let members = composite_members(entity)?;
366            let mut curves = Vec::with_capacity(members.len());
367            for m in members {
368                let member = file.entity(m)?;
369                if member.de.entity_type != 126 {
370                    return Err(format!(
371                        "iges_import: composite member is entity type {} (expected 126)",
372                        member.de.entity_type
373                    ));
374                }
375                curves.push(curve_from_126(member, coord_scale)?);
376            }
377            Ok(curves)
378        }
379        126 => Ok(vec![curve_from_126(entity, coord_scale)?]),
380        other => Err(format!(
381            "iges_import: boundary curve is entity type {other} (expected 102 or 126)"
382        )),
383    }
384}
385
386/// Check that a parameter-space curve evaluated on the surface reproduces the
387/// model-space curve at their common midpoint.
388fn faithful_pcurves_agree(surface: &NurbsSurface, model: &NurbsCurve, param: &NurbsCurve) -> bool {
389    let evaluate = || -> Result<bool, String> {
390        let [pt0, pt1] = param.domain()?;
391        let mid = 0.5 * (pt0 + pt1);
392        let uv = param.evaluate(mid)?;
393        let on_surface = surface.evaluate(uv.x, uv.y)?;
394        let [mt0, mt1] = model.domain()?;
395        let model_mid = model.evaluate(0.5 * (mt0 + mt1))?;
396        let tol = 1e-4 * (1.0 + model_mid.length());
397        Ok(on_surface.sub(model_mid).length() <= tol)
398    };
399    evaluate().unwrap_or(false)
400}
401
402/// Derive a pcurve for a foreign file by projecting the 3D curve onto the
403/// carrier surface.
404fn derive_pcurve(surface: &NurbsSurface, model: &NurbsCurve) -> Result<NurbsCurve, String> {
405    let [t0, t1] = model.domain()?;
406    let magnitude = model.evaluate(0.5 * (t0 + t1)).map(|p| p.length()).unwrap_or(1.0);
407    let tol = 1e-6 * (1.0 + magnitude);
408    build_pcurve_on_surface_range(surface, model, t0, t1, true, tol)
409}
410
411/// Signed area of a loop in surface parameter space (shoelace over sampled
412/// pcurve points). Positive = counter-clockwise in `(u, v)`.
413fn loop_param_signed_area(loop_record: &LoopRecord) -> f64 {
414    let mut pts: Vec<(f64, f64)> = Vec::new();
415    for coedge in &loop_record.coedges {
416        let curve = &coedge.pcurve;
417        let [t0, t1] = match curve.domain() {
418            Ok(d) => d,
419            Err(_) => continue,
420        };
421        // Sample the interior of each pcurve; endpoints are shared with
422        // neighbours so the concatenation forms the closed loop polygon.
423        const SAMPLES: usize = 8;
424        for i in 0..SAMPLES {
425            let t = t0 + (t1 - t0) * i as f64 / SAMPLES as f64;
426            if let Ok(p) = curve.evaluate(t) {
427                pts.push((p.x, p.y));
428            }
429        }
430    }
431    if pts.len() < 3 {
432        return 0.0;
433    }
434    let mut area = 0.0;
435    for i in 0..pts.len() {
436        let (x0, y0) = pts[i];
437        let (x1, y1) = pts[(i + 1) % pts.len()];
438        area += x0 * y1 - x1 * y0;
439    }
440    0.5 * area
441}
442
443/// Multi-station collapse test: true when the curve stays within tolerance of a
444/// single point (a pole/degenerate edge), distinguishing it from a closed loop.
445fn curve_collapsed(curve: &NurbsCurve, at: Vec3) -> bool {
446    let tol = 1e-7 * (1.0 + at.length());
447    let [t0, t1] = match curve.domain() {
448        Ok(d) => d,
449        Err(_) => return false,
450    };
451    for index in 1..8 {
452        let t = t0 + (t1 - t0) * index as f64 / 8.0;
453        match curve.evaluate(t) {
454            Ok(p) => {
455                if p.sub(at).length() > tol {
456                    return false;
457                }
458            }
459            Err(_) => return false,
460        }
461    }
462    true
463}