diff --git a/contributors/people.toml b/contributors/people.toml index 3f54de7a..613f7c0e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -26,6 +26,11 @@ sync_github_name = false display_name = "by Truitt" sync_github_name = false +[people.tsangiotis] +real_name = "Tasos Sangiotis" +display_name = "Tasos Sangiotis" +sync_github_name = false + [people.vangel76] sync_github_name = true diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index a37a2892..a2e6a0d7 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -621,8 +621,15 @@ fn hatch(tags: &[Tag], t: &T) -> Option { if verts.last().is_none_or(|l| !l.p.near(p0, 1e-9)) { verts.push(PolyVertex::new(p0)); } + // A full circle (0 to 360) starts and ends on the same point, which one + // bulge can't span: it goes in as two half circles. + let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9; + let bulge = cadcraft_geom::arc_to_bulge(if full { sweep / 2.0 } else { sweep }); if let Some(l) = verts.last_mut() { - l.bulge = cadcraft_geom::arc_to_bulge(sweep); + l.bulge = bulge; + } + if full { + verts.push(PolyVertex::with_bulge(Vec2::polar(c, r, s + sweep / 2.0), bulge)); } verts.push(PolyVertex::new(p1)); } @@ -633,32 +640,73 @@ fn hatch(tags: &[Tag], t: &T) -> Option { let ratio = f(i + 4); let sa = f(i + 5).to_radians(); let ea = f(i + 6).to_radians(); + let ccw = get(i + 7).filter(|t| t.code == 73).map(Tag::i64).unwrap_or(1) != 0; i += 8; - let e = cadcraft_geom::Ellipse { center: c, major: m, ratio, start: sa, end: ea }; + // A clockwise edge stores its parameters mirrored, as an arc edge does: it + // runs from -start clockwise to -end, the ccw sweep from -end to -start + // walked backwards. + let (s, e) = if ccw { (sa, ea) } else { (-ea, -sa) }; + let e = cadcraft_geom::Ellipse { center: c, major: m, ratio, start: s, end: e }; let mut pts = Vec::new(); e.tessellate(m.len() * 1e-3, &mut pts); + if !ccw { + pts.reverse(); + } verts.extend(pts.into_iter().map(PolyVertex::new)); } 4 => { - // Spline edge: 94 degree, 73 rational, 74 periodic, 95 knots, 96 ctrl. - let _deg = get(i).map(Tag::i64).unwrap_or(3); - while get(i).is_some_and(|x| x.code != 96) { + // Spline edge: 94 degree, 73 rational, 74 periodic, 95 knot count, 96 + // control point count, then the 40 knots, the 10/20 control points (each + // with a 42 weight when rational), and 97 fit point count with 11/21 fit + // points and 12/22, 13/23 end tangents (R2010+). + let (mut degree, mut nk, mut nc) = (3, 0, 0); + while let Some(x) = get(i).filter(|x| matches!(x.code, 94 | 73 | 74 | 95 | 96)) { + let n = x.i64().clamp(0, 100_000) as usize; + match x.code { + 94 => degree = n, + 95 => nk = n, + 96 => nc = n, + _ => {} + } i += 1; } - let nc = get(i).map(Tag::i64).unwrap_or(0).clamp(0, 100_000) as usize; - i += 1; - let mut ctrl = Vec::new(); - for _ in 0..nc { - if get(i).is_some_and(|t| t.code == 10) { - ctrl.push(Vec2::new(f(i), f(i + 1))); - i += 2; - if get(i).is_some_and(|t| t.code == 42) { - i += 1; - } + let mut knots = Vec::new(); + while knots.len() < nk && get(i).is_some_and(|x| x.code == 40) { + knots.push(f(i)); + i += 1; + } + let (mut control, mut weights) = (Vec::new(), Vec::new()); + while control.len() < nc && get(i).is_some_and(|x| x.code == 10) { + control.push(Vec2::new(f(i), f(i + 1))); + i += 2; + if get(i).is_some_and(|t| t.code == 42) { + weights.push(f(i)); + i += 1; } } - let sp = Spline::from_control(ctrl, 3); - verts.extend(sp.tessellate(1e-3).into_iter().map(PolyVertex::new)); + let mut fit = Vec::new(); + if get(i).is_some_and(|x| x.code == 97) { + let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, 100_000) as usize; + i += 1; + while fit.len() < n && get(i).is_some_and(|x| x.code == 11) { + fit.push(Vec2::new(f(i), f(i + 1))); + i += 2; + } + } + if weights.len() != control.len() { + weights.clear(); + } + let sp = Spline { degree, knots, control, weights, ..Spline::default() }; + let sp = if sp.is_valid() { + sp + } else if sp.control.is_empty() && fit.len() > 1 { + Spline::from_fit_points(&fit) + } else { + Spline::from_control(sp.control, degree) + }; + let pts = sp.tessellate(1e-3); + let skip = usize::from(verts.last().zip(pts.first()).is_some_and(|(l, p)| l.p.near(*p, 1e-9))); + verts.extend(pts.into_iter().skip(skip).map(PolyVertex::new)); } _ => {} } diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index e97ec468..fc26a410 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1762,6 +1762,36 @@ fn mirrored_hatch_extrusion_flips_boundary() { } } +/// The model extents of a one-loop solid HATCH whose edge boundary is `edges` (group 72 onwards). +fn hatch_edge_extents(edges: &str) -> Bounds2 { + let n = edges.matches("72\n").count(); + let text = format!( + "0\nSECTION\n2\nENTITIES\n0\nHATCH\n8\n0\n10\n0\n20\n0\n30\n0\n210\n0\n220\n0\n230\n1\n2\nSOLID\n70\n1\n71\n0\n\ + 91\n1\n92\n1\n93\n{n}\n{edges}97\n0\n75\n0\n76\n1\n98\n0\n0\nENDSEC\n0\nEOF\n" + ); + read(text.as_bytes(), "h.dxf").unwrap().extents(&Space::Model) +} + +/// Issues #492, #493, #494: a full-circle arc edge, spline edges (knots come before the control +/// points) and clockwise ellipse edges all keep their curve. +#[test] +fn hatch_edges_keep_full_circles_splines_and_clockwise_ellipses() { + let near = |b: Bounds2, want: [f64; 4]| { + let got = [b.min.x, b.min.y, b.max.x, b.max.y]; + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "extents {got:?}, want {want:?}"); + }; + // #492: one arc edge from 0 to 360. + near(hatch_edge_extents("72\n2\n10\n0\n20\n0\n40\n5\n50\n0\n51\n360\n73\n1\n"), [-5.0, -5.0, 5.0, 5.0]); + // #493: a cubic Bezier through (0,0)..(4,0) peaking at y = 3, closed by a line. + let spline = "72\n4\n94\n3\n73\n0\n74\n0\n95\n8\n96\n4\n40\n0\n40\n0\n40\n0\n40\n0\n40\n1\n40\n1\n40\n1\n40\n1\n\ + 10\n0\n20\n0\n10\n0\n20\n4\n10\n4\n20\n4\n10\n4\n20\n0\n97\n0\n"; + near(hatch_edge_extents(&format!("{spline}72\n1\n10\n4\n20\n0\n11\n0\n21\n0\n")), [0.0, 0.0, 4.0, 3.0]); + // #494: the clockwise half-ellipse from 0 to 180 is the lower half, closed by its diameter. + let ellipse = |ccw| format!("72\n3\n10\n0\n20\n0\n11\n5\n21\n0\n40\n0.5\n50\n0\n51\n180\n73\n{ccw}\n72\n1\n10\n-5\n20\n0\n11\n5\n21\n0\n"); + near(hatch_edge_extents(&ellipse(0)), [-5.0, -2.5, 5.0, 0.0]); + near(hatch_edge_extents(&ellipse(1)), [-5.0, 0.0, 5.0, 2.5]); +} + #[test] fn mirrored_text_extrusion_keeps_its_footprint() { // TEXT at OCS (-10,5) under extrusion (0,0,-1) starts at WCS (10,5) and runs towards -x (with