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