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 <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-10-21 02:20:02 +00:00
committed by GitHub
parent 776541b081
commit e94900933d
2 changed files with 159 additions and 19 deletions
+135 -19
View File
@@ -429,6 +429,8 @@ struct Tr<'a> {
curve_work: usize,
/// Composite curves (102) on the path being expanded.
composites: Vec<i64>,
/// 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<usize> {
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>), 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>), 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<Vec<Seg>>,
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<usize> = 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<usize, (V3, usize)> = 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<usize, V3> = sums.into_iter().map(|(r, (s, n))| (r, mul(s, 1.0 / n as f64))).collect();
let mut to: Vec<Option<V3>> = 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<FaceB> {
fn trimmed(t: &mut Tr, tol: f64, seq: i64) -> R<Draft> {
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<Seg>| -> 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<Seg>| -> Vec<Seg> { 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<FaceB> {
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<FaceB> {
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 })
}
}
}
+24
View File
@@ -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();