diff --git a/crates/kernel/src/iges_in.rs b/crates/kernel/src/iges_in.rs index cbb6716ecc..d9f1f7f874 100644 --- a/crates/kernel/src/iges_in.rs +++ b/crates/kernel/src/iges_in.rs @@ -660,6 +660,8 @@ impl<'a> Tr<'a> { self.nurbs_curve_in(seq, x, self.f.unit) } fn nurbs_curve_in(&self, seq: i64, x: &Xf, unit: f64) -> R { + /// Knot spans shorter than this are moved onto (0, 1). + const MIN_KNOT_SPAN: f64 = 1e-4; let k = self.i(seq, 1)?; let m = self.i(seq, 2)?; if !(0..=MAX_LIST as i64).contains(&k) || !(1..=32).contains(&m) || m > k.max(1) + 1 { @@ -685,11 +687,21 @@ impl<'a> Tr<'a> { if knots.windows(2).any(|w| w[1] < w[0]) { return Err(format!("entity {seq}: knots out of order")); } - let nb = Nurbs { deg: m, knots, pts }; + let mut nb = Nurbs { deg: m, knots, pts }; let (a, b) = nb.range(); if !(b > a) { return Err(format!("entity {seq}: empty parameter range")); } + // Some exporters write a curve over a knot span of 1e-7 or less. The STEP side reads a + // span that small as a single value and refused the curve, losing the faces it bounds. + // Moving and scaling the knots onto (0, 1) leaves the curve's shape as it is; nothing + // else refers to its parameters. + if b - a < MIN_KNOT_SPAN { + let scale = 1.0 / (b - a); + for k in &mut nb.knots { + *k = (*k - a) * scale; + } + } Ok(nb) } diff --git a/crates/kernel/src/iges_in/tests.rs b/crates/kernel/src/iges_in/tests.rs index c10950c364..5aa7738a27 100644 --- a/crates/kernel/src/iges_in/tests.rs +++ b/crates/kernel/src/iges_in/tests.rs @@ -342,3 +342,42 @@ fn a_hole_whose_first_trim_piece_is_reversed_stays_a_hole() { let m = measure(&imp.bodies[0].body).unwrap(); assert!(rel(m.area, 84.0) < 1e-6, "area {}", m.area); } + +/// The box of `trimmed_surfaces_sew_into_a_solid` with one edge written as a B-spline (126) +/// over a knot span of 1e-7 or less, as some exporters write short curves: the STEP side read +/// that span as a single value and refused the curve, and both faces it bounds were lost. +#[test] +fn a_curve_over_a_tiny_knot_span_still_bounds_its_faces() { + let c = + |i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] }; + let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]]; + let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]); + for span in [1e-7f64, 1e-9, 1e-13] { + let mut ents: Vec<(i64, i64, String)> = Vec::new(); + for (fi, q) in quads.iter().enumerate() { + let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3])); + let surf = format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(a), fmt(b), fmt(d), fmt(cc)); + ents.push((128, 0, surf)); + let s_de = 2 * ents.len() - 1; + let mut lines = Vec::new(); + for (k, (p0, p1)) in [(a, b), (b, cc), (cc, d), (d, a)].into_iter().enumerate() { + if fi == 0 && k == 0 { + ents.push((126, 0, format!("1,1,0,0,1,0,0.,0.,{span:E},{span:E},1.,1.,{},{},0.,{span:E},0.,0.,1.", fmt(p0), fmt(p1)))); + } else { + ents.push((110, 0, format!("{},{}", fmt(p0), fmt(p1)))); + } + lines.push(2 * ents.len() - 1); + } + ents.push((102, 0, format!("4,{}", lines.iter().map(|x| x.to_string()).collect::>().join(",")))); + let comp = 2 * ents.len() - 1; + ents.push((142, 0, format!("0,{s_de},0,{comp},2"))); + let cos = 2 * ents.len() - 1; + ents.push((144, 0, format!("{s_de},1,0,{cos}"))); + } + let imp = iges_import(&iges_file(&ents)).unwrap(); + assert!(imp.warnings.is_empty(), "span {span}: {:?}", imp.warnings); + assert!(imp.bodies[0].closed, "span {span}"); + let m = measure(&imp.bodies[0].body).unwrap(); + assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "span {span}: {} {}", m.volume, m.area); + } +}