From 7f48664fed08d3b7255aa6a294d478aa04581593 Mon Sep 17 00:00:00 2001 From: el-j Date: Wed, 28 Oct 2026 10:50:06 +0100 Subject: [PATCH 1/3] test(cad): add regression test for closed sliver along a circle in DXF (#4151) --- crates/cad/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 46b09f5ff..94ae6cf6a 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -246,6 +246,39 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() { assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE")); } +#[test] +fn a_closed_sliver_along_a_circle_is_not_a_circle() { + let d = doc_with(|d| { + let (cx, cy, r) = (60.0, 250.0, 200.0); + let at = |deg: f64| { + let rad = deg.to_radians(); + Point::new(cx + r * rad.cos(), cy + r * rad.sin()) + }; + let along = |sliver: &mut vectorcraft_geom::BezPath, deg0: f64, deg1: f64| { + let p0 = at(deg0); + let p3 = at(deg1); + let step = (deg1 - deg0).to_radians(); + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let r0 = deg0.to_radians(); + let r1 = deg1.to_radians(); + let p1 = Point::new(p0.x - r * r0.sin() * k, p0.y + r * r0.cos() * k); + let p2 = Point::new(p3.x + r * r1.sin() * k, p3.y - r * r1.cos() * k); + sliver.curve_to(p1, p2, p3); + }; + let mut sliver = vectorcraft_geom::BezPath::new(); + sliver.move_to(at(-110.0)); + along(&mut sliver, -110.0, -90.0); + along(&mut sliver, -90.0, -70.0); + along(&mut sliver, -70.0, -110.0); + sliver.close_path(); + vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)] + }); + + let modern = export(&d, &opts(&d)).unwrap(); + assert_ne!(kinds(&modern), ["CIRCLE"]); + assert_eq!(kinds(&modern), ["SPLINE"]); +} + #[test] fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() { let cases = [(true, true), (true, false), (false, false), (false, true)]; From a24326e04625e39291cdb19521b038ac85e682e0 Mon Sep 17 00:00:00 2001 From: el-j Date: Wed, 28 Oct 2026 10:52:00 +0100 Subject: [PATCH 2/3] fix(cad): verify circular subpaths complete a full turn without reversing (#4151) --- crates/cad/src/scene.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 1e7baac26..d4cd3ec30 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -126,6 +126,36 @@ fn circular_subpath(sub: &Sub) -> Option { if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) { return None; } + // Verify that the curve progresses monotonically around the circle without reversing, + // and that closed subpaths make exactly one full turn while open subpaths do not exceed one turn. + let mut total_turn = 0.0; + let mut dir: Option = None; + for w in samples.windows(2) { + let (p0, p1) = (w[0], w[1]); + let (v0x, v0y) = (p0.x - centre.x, p0.y - centre.y); + let (v1x, v1y) = (p1.x - centre.x, p1.y - centre.y); + let cross = v0x * v1y - v0y * v1x; + let dot = v0x * v1x + v0y * v1y; + let d_theta = cross.atan2(dot); + if d_theta.abs() > 1e-9 { + let is_ccw = d_theta > 0.0; + if let Some(expected_ccw) = dir { + if is_ccw != expected_ccw { + return None; + } + } else { + dir = Some(is_ccw); + } + total_turn += d_theta; + } + } + if sub.closed { + if (total_turn.abs() - std::f64::consts::TAU).abs() > 1e-3 { + return None; + } + } else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 { + return None; + } Some(CircularSub { centre, radius, samples }) } From c0d7d22d426445141a9847cb50d6e76bf9cb7b28 Mon Sep 17 00:00:00 2001 From: el-j Date: Wed, 28 Oct 2026 22:35:51 +0100 Subject: [PATCH 3/3] Fix #4151: verify continuous angular direction for cubic arc conversion (#4151) --- crates/cad/src/scene.rs | 25 +++++++++++++++++++++++++ crates/cad/src/tests.rs | 13 +++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index d4cd3ec30..7ab1d17f8 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -127,9 +127,34 @@ fn circular_subpath(sub: &Sub) -> Option { return None; } // Verify that the curve progresses monotonically around the circle without reversing, + // both along each cubic segment's angular derivative and across sampled intervals, // and that closed subpaths make exactly one full turn while open subpaths do not exceed one turn. let mut total_turn = 0.0; let mut dir: Option = None; + let ang_threshold = radius * radius * 1e-9; + let mut seg_from = sub.start; + for seg in &sub.segs { + let Seg::Cubic(a, b, to) = *seg else { return None }; + for step in 0..=32 { + let t = f64::from(step) / 32.0; + let p = cubic_point(seg_from, a, b, to, t); + let u = 1.0 - t; + let dx = 3.0 * u * u * (a.x - seg_from.x) + 6.0 * u * t * (b.x - a.x) + 3.0 * t * t * (to.x - b.x); + let dy = 3.0 * u * u * (a.y - seg_from.y) + 6.0 * u * t * (b.y - a.y) + 3.0 * t * t * (to.y - b.y); + let cross = (p.x - centre.x) * dy - (p.y - centre.y) * dx; + if cross.abs() > ang_threshold { + let is_ccw = cross > 0.0; + if let Some(expected_ccw) = dir { + if is_ccw != expected_ccw { + return None; + } + } else { + dir = Some(is_ccw); + } + } + } + seg_from = to; + } for w in samples.windows(2) { let (p0, p1) = (w[0], w[1]); let (v0x, v0y) = (p0.x - centre.x, p0.y - centre.y); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 94ae6cf6a..30a8d1a81 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -279,6 +279,19 @@ fn a_closed_sliver_along_a_circle_is_not_a_circle() { assert_eq!(kinds(&modern), ["SPLINE"]); } +#[test] +fn cubic_with_hidden_angular_reversal_is_not_promoted_to_arc() { + let d = doc_with(|d| { + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to(Point::new(-8.404028665134, 62.061475842818)); + b.curve_to(Point::new(-13.285253116737, 62.103123153545), Point::new(52.811163576591, 52.471253544459), Point::new(60.0, 50.0)); + vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)] + }); + let dxf = export(&d, &opts(&d)).unwrap(); + assert_ne!(kinds(&dxf), ["ARC"]); + assert_eq!(kinds(&dxf), ["SPLINE"]); +} + #[test] fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() { let cases = [(true, true), (true, false), (false, false), (false, true)];