From c0d7d22d426445141a9847cb50d6e76bf9cb7b28 Mon Sep 17 00:00:00 2001 From: el-j Date: Wed, 28 Oct 2026 22:35:51 +0100 Subject: [PATCH] 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)];