From e94900933dafd63e2b32594afbdc879ecd64d2f7 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 21 Oct 2026 02:20:02 +0000 Subject: [PATCH] IGES import: close small gaps between trim pieces (#3845) (#3846) Consecutive pieces of a trim loop can miss each other by micrometres to a tenth of a millimetre, past the file's resolution, so the loop did not close and the face was dropped. Faces are now read first and sewn after: each small gap's ends, and the other piece ends at them, move to one vertex. Gaps over 0.1 mm or not small next to their pieces stay open; the sewing tolerance stays the file's. The stated accuracy covers how far a vertex moved, so the STEP reader keeps those edges. Co-authored-by: Claude Opus 5.5 --- crates/kernel/src/iges_in.rs | 154 +++++++++++++++++++++++++---- crates/kernel/src/iges_in/tests.rs | 24 +++++ 2 files changed, 159 insertions(+), 19 deletions(-) diff --git a/crates/kernel/src/iges_in.rs b/crates/kernel/src/iges_in.rs index b7352c52cb..ccdf1949bc 100644 --- a/crates/kernel/src/iges_in.rs +++ b/crates/kernel/src/iges_in.rs @@ -429,6 +429,8 @@ struct Tr<'a> { curve_work: usize, /// Composite curves (102) on the path being expanded. composites: Vec, + /// Farthest a trim loop's end was moved to close a gap (mm; see `close_gaps`). + snapped: f64, } fn xf_key(x: &Xf) -> [u64; 12] { @@ -1034,24 +1036,35 @@ struct Sewer { } impl Sewer { - fn vertex(&mut self, p: V3) -> usize { + fn key(&self, p: V3) -> [i64; 3] { // Neighbouring buckets are this key plus or minus one, so leave room for that: a // coordinate near f64's range saturates the cast and the addition would overflow. let c = |x: f64| (x / self.tol).floor().clamp(-9e15, 9e15) as i64; - let key = [c(p[0]), c(p[1]), c(p[2])]; + [c(p[0]), c(p[1]), c(p[2])] + } + /// The vertex within the tolerance of `p`, if there is one. + fn find(&self, p: V3) -> Option { + let key = self.key(p); for dx in -1..=1 { for dy in -1..=1 { for dz in -1..=1 { if let Some(list) = self.grid.get(&[key[0] + dx, key[1] + dy, key[2] + dz]) { for &i in list { if self.verts.get(i).is_some_and(|q| norm(sub(*q, p)) <= self.tol) { - return i; + return Some(i); } } } } } } + None + } + fn vertex(&mut self, p: V3) -> usize { + if let Some(i) = self.find(p) { + return i; + } + let key = self.key(p); self.verts.push(p); self.grid.entry(key).or_default().push(self.verts.len() - 1); self.verts.len() - 1 @@ -1227,12 +1240,21 @@ fn translate(text: &str) -> std::result::Result<(String, Vec), String> { } let f = parse(text)?; let tol = (f.resolution * f.unit).clamp(1e-6, 0.05).max(1e-5); - let mut t = - Tr { f: &f, o: Out { lines: Vec::new() }, warnings: Vec::new(), tol, surf_cache: HashMap::new(), curve_work: 0, composites: Vec::new() }; + let mut t = Tr { + f: &f, + o: Out { lines: Vec::new() }, + warnings: Vec::new(), + tol, + surf_cache: HashMap::new(), + curve_work: 0, + composites: Vec::new(), + snapped: 0.0, + }; // Header: units and context. let mm = t.o.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))"); let rad = t.o.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))"); let sr = t.o.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())"); + let unc_line = t.o.lines.len(); let unc = t.o.add(format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol))); let ctx = t.o.add(format!( "(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{mm},#{rad},#{sr}))REPRESENTATION_CONTEXT('',''))" @@ -1278,6 +1300,13 @@ fn translate(text: &str) -> std::result::Result<(String, Vec), String> { } } assembly.write(&mut t, &seqs, &solids, &mut styles, ctx)?; + if t.snapped > 0.0 { + // The STEP reader takes vertices up to ten times the stated accuracy off their curves: + // state enough to cover the ends `close_gaps` moved, and no more. + if let Some(l) = t.o.lines.get_mut(unc_line) { + *l = format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol.max(t.snapped * 0.2))); + } + } for (item, c) in styles { let rgb = t.o.add(format!("COLOUR_RGB('',{},{},{})", r(c[0] as f64), r(c[1] as f64), r(c[2] as f64))); let fasc = t.o.add(format!("FILL_AREA_STYLE_COLOUR('',#{rgb})")); @@ -1313,15 +1342,21 @@ fn sew_surfaces( sw.edges.clear(); sw.by_ends.clear(); let watch = crate::watch::current(); + let mut drafts = Vec::new(); for seq in seqs { if watch.as_ref().is_some_and(|w| w.cancelled()) { return Err("IGES import cancelled".into()); } - match trimmed(t, sw, seq as i64) { - Ok(face) => faces.push(face), + match trimmed(t, sw.tol, seq as i64) { + Ok(face) => drafts.push(face), Err(e) => t.warn(format!("surface {seq}: not read: {e}")), } } + close_gaps(t, &mut drafts, sw.tol); + for d in drafts { + let loops = d.loops.iter().map(|l| l.iter().map(|s| sw.edge(s)).collect()).collect(); + faces.push(FaceB { surface: d.surface, sense: true, loops, color: d.color }); + } if faces.is_empty() { return Ok(None); } @@ -1336,15 +1371,96 @@ fn sew_surfaces( })) } +/// A face read from a trimmed or bounded surface, its loops not yet sewn. +struct Draft { + surface: u64, + loops: Vec>, + color: Option<[f32; 3]>, +} + +/// Gaps between the pieces of a trim loop wider than this (mm) are left open: past it, the ends +/// may be different corners of the model rather than one corner written loosely. +const MAX_TRIM_GAP: f64 = 0.1; +/// ...and a gap must also be at most this share of the shorter piece on either side. +const MAX_TRIM_GAP_SHARE: f64 = 0.05; + +/// Close the small gaps between consecutive pieces of the faces' trim loops. Some exporters +/// write each piece's model-space curve separately, and consecutive pieces can then miss by a +/// few micrometres to a tenth of a millimetre, well past the file's stated resolution: the loop +/// does not close, its face is lost and the shell stays open. The two ends of each such gap, and +/// every other piece end at either of them (the neighbouring faces at that corner), move to +/// their mean, so all the faces there sew to one vertex. A gap wider than `MAX_TRIM_GAP`, or not +/// small next to its pieces, is left open; the sewing tolerance stays the file's. +fn close_gaps(t: &mut Tr, faces: &mut [Draft], tol: f64) { + let length = |s: &Seg| norm(sub(s.q, s.a)) + norm(sub(s.mid, s.q)) + norm(sub(s.q3, s.mid)) + norm(sub(s.b, s.q3)); + // The gaps' ends as vertices, joined into groups (union-find). + let mut ends = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() }; + let mut parent: Vec = Vec::new(); + let root = |parent: &[usize], mut i: usize| { + while let Some(&p) = parent.get(i).filter(|p| **p != i) { + i = p; + } + i + }; + for l in faces.iter().flat_map(|f| f.loops.iter()) { + for (i, prev) in l.iter().enumerate() { + let Some(next) = l.get((i + 1) % l.len()) else { continue }; + let gap = norm(sub(next.a, prev.b)); + if gap <= tol || gap > MAX_TRIM_GAP || gap > MAX_TRIM_GAP_SHARE * length(prev).min(length(next)) { + continue; + } + let (a, b) = (ends.vertex(prev.b), ends.vertex(next.a)); + while parent.len() < ends.verts.len() { + parent.push(parent.len()); + } + let (ra, rb) = (root(&parent, a), root(&parent, b)); + if let Some(x) = parent.get_mut(ra) { + *x = rb; + } + } + } + if ends.verts.is_empty() { + return; + } + // Each group's mean; a group that spreads wider than a gap may be is left alone. + let mut sums: HashMap = HashMap::new(); + for (i, p) in ends.verts.iter().enumerate() { + let e = sums.entry(root(&parent, i)).or_insert(([0.0; 3], 0)); + e.0 = add(e.0, *p); + e.1 += 1; + } + let mean: HashMap = sums.into_iter().map(|(r, (s, n))| (r, mul(s, 1.0 / n as f64))).collect(); + let mut to: Vec> = ends.verts.iter().enumerate().map(|(i, _)| mean.get(&root(&parent, i)).copied()).collect(); + for (i, p) in ends.verts.iter().enumerate() { + if to.get(i).copied().flatten().is_some_and(|m| norm(sub(m, *p)) > MAX_TRIM_GAP / 2.0) { + let r = root(&parent, i); + for (k, x) in to.iter_mut().enumerate() { + if root(&parent, k) == r { + *x = None; + } + } + } + } + let mut moved = 0.0f64; + let mut snap = |p: &mut V3| { + if let Some(m) = ends.find(*p).and_then(|i| to.get(i).copied().flatten()) { + moved = moved.max(norm(sub(m, *p))); + *p = m; + } + }; + for s in faces.iter_mut().flat_map(|f| f.loops.iter_mut()).flat_map(|l| l.iter_mut()) { + snap(&mut s.a); + snap(&mut s.b); + } + t.snapped = t.snapped.max(moved); +} + /// A trimmed (144) or bounded (143) surface, or a bare B-spline surface, as a face. -fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R { +fn trimmed(t: &mut Tr, tol: f64, seq: i64) -> R { let de = t.de(seq)?; let xf = t.own_xf(seq, 0)?; let color = color_of(t, seq); - let loop_of = |_t: &mut Tr, sw: &mut Sewer, segs: Vec| -> Vec<(usize, bool)> { - let tol = sw.tol; - segs.iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).map(|s| sw.edge(s)).collect() - }; + let loop_of = |segs: Vec| -> Vec { segs.into_iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).collect() }; match de.typ { 144 => { let surf_seq = t.i(seq, 1)?; @@ -1353,16 +1469,16 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R { let n2 = t.i(seq, 3)?.clamp(0, MAX_LIST as i64) as usize; let mut loops = Vec::new(); let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf, surf_seq)? }; - loops.push(loop_of(t, sw, outer)); + loops.push(loop_of(outer)); for k in 0..n2 { let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf, surf_seq)?; - loops.push(loop_of(t, sw, segs)); + loops.push(loop_of(segs)); } loops.retain(|l| !l.is_empty()); if loops.is_empty() { return Err("no boundary".into()); } - Ok(FaceB { surface, sense: true, loops, color }) + Ok(Draft { surface, loops, color }) } 143 => { let surf_seq = t.i(seq, 2)?; @@ -1372,19 +1488,19 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R { for k in 0..n { let b = t.i(seq, 4 + k)?; let segs = boundary_141(t, b, &xf)?; - loops.push(loop_of(t, sw, segs)); + loops.push(loop_of(segs)); } loops.retain(|l| !l.is_empty()); if loops.is_empty() { return Err("no boundary".into()); } - Ok(FaceB { surface, sense: true, loops, color }) + Ok(Draft { surface, loops, color }) } _ => { let surface = t.surface(seq, &Xf::ID)?; let outer = t.natural_boundary(seq, &Xf::ID)?; - let l = loop_of(t, sw, outer); - Ok(FaceB { surface, sense: true, loops: vec![l], color }) + let l = loop_of(outer); + Ok(Draft { surface, loops: vec![l], color }) } } } diff --git a/crates/kernel/src/iges_in/tests.rs b/crates/kernel/src/iges_in/tests.rs index a5c614d732..c05022c864 100644 --- a/crates/kernel/src/iges_in/tests.rs +++ b/crates/kernel/src/iges_in/tests.rs @@ -299,6 +299,30 @@ fn trimmed_surfaces_sew_into_a_solid() { assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area); } +/// Trim pieces whose ends miss each other by a hundredth of a millimetre, far past the file's +/// stated resolution: the loop is closed and the box sews into a solid. A gap of half a +/// millimetre is no corner written loosely, and stays open. +#[test] +fn trim_loops_with_small_gaps_still_close() { + let with_gap = |gap: f64| { + let mut ents = Vec::new(); + trimmed_box(&mut ents, 0.0); + // The edge from (0, 0, 0) towards (0, 20, 0) stops `gap` short of that corner, in both + // faces that run along it (the first face's first line, the fifth face's last). + ents[1].2 = format!("0.,0.,0.,0.,{:?},0.", 20.0 - gap); + ents[36].2 = format!("0.,{:?},0.,0.,0.,0.", 20.0 - gap); + iges_import(&iges_file(&ents)) + }; + let imp = with_gap(0.01).unwrap(); + assert!(imp.warnings.is_empty(), "{:?}", imp.warnings); + assert!(imp.bodies[0].closed); + let m = measure(&imp.bodies[0].body).unwrap(); + assert!(rel(m.volume, 6000.0) < 1e-3 && rel(m.area, 2200.0) < 1e-3, "{} {}", m.volume, m.area); + if let Ok(imp) = with_gap(0.5) { + assert!(imp.bodies.iter().all(|b| !b.closed), "a 0.5 mm gap was closed"); + } +} + #[test] fn surfaces_in_separate_groups_remain_separate_parts() { let mut ents = Vec::new();