DXF: keep full-circle, spline and clockwise ellipse hatch edges (fixes #492, #493, #494) (#549)

Squash of PR #549 by @tsangiotis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tasos Sangiotis 2026-10-11 20:23:45 +03:00 committed by GitHub
parent 8f59410c33
commit a880f8a4b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 100 additions and 17 deletions

View File

@ -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

View File

@ -621,8 +621,15 @@ fn hatch(tags: &[Tag], t: &T) -> Option<EntityKind> {
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<EntityKind> {
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));
}
_ => {}
}

View File

@ -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