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