diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs index d60d91456..7e4f18f0f 100644 --- a/crates/dxf/src/paths.rs +++ b/crates/dxf/src/paths.rs @@ -55,7 +55,11 @@ pub fn import(bytes: &[u8]) -> Result { if estimated_elements(&entity.kind) > remaining { return Err(Error::TooManySegments); } - let converted = entity_paths(&entity.kind, entity.common.extrusion); + let converted = if let crate::model::EntityKind::Spline(spline) = &entity.kind { + spline_path(spline)?.into_iter().collect() + } else { + entity_paths(&entity.kind, entity.common.extrusion) + }; if converted.iter().flat_map(|path| path.elements()).any(|element| { let finite = |p: Point| p.x.is_finite() && p.y.is_finite(); match *element { @@ -128,6 +132,9 @@ fn estimated_elements(kind: &crate::model::EntityKind) -> usize { polyline_count(&vertices, poly.closed()) } E::Ellipse { start, end, .. } => 1usize.saturating_add(arc_count(end - start)), + // `spline_path` already caps subdivision at MAX_SPLINE_ELEMENTS. Its actual output is + // checked against the drawing-wide budget after conversion. + E::Spline(_) => 0, _ => 0, } } @@ -191,6 +198,278 @@ fn entity_paths(kind: &crate::model::EntityKind, normal: crate::model::Point) -> } } +const MAX_SPLINE_ELEMENTS: usize = 200_000; +const SPLINE_TOLERANCE: f64 = 1e-6; +const MAX_SPLINE_DEPTH: u32 = 12; +const MAX_SPLINE_DEGREE: usize = 11; +const MIN_RELATIVE_WEIGHT: f64 = 1e-100; + +#[derive(Clone, Copy)] +struct Homogeneous { + x: f64, + y: f64, + w: f64, +} + +impl Homogeneous { + fn lerp(self, other: Self, t: f64) -> Self { + let s = 1.0 - t; + Self { x: self.x * s + other.x * t, y: self.y * s + other.y * t, w: self.w * s + other.w * t } + } + fn point(self) -> Option { + (self.w.abs() > 1e-300).then(|| Point::new(self.x / self.w, self.y / self.w)).filter(|point| point.x.is_finite() && point.y.is_finite()) + } +} + +// Keep the NURBS conversion semantics of the previous importer: exact polynomial Bezier +// spans through cubic degree, tolerance-bounded rational/high-degree spans, invalid-weight +// fallback, and fit-point fallback when control data is malformed. +fn spline_path(spline: &crate::model::Spline) -> Result, Error> { + let weights = (spline.flags & 4 != 0 && spline.weights.len() == spline.control.len()).then_some(spline.weights.as_slice()); + let mut limit_hit = false; + let converted = usize::try_from(spline.degree).ok().and_then(|degree| nurbs(degree, &spline.knots, &spline.control, weights, &mut limit_hit)); + let path = converted + .map(|mut path| { + if spline.flags & 1 != 0 { + path.close_path(); + } + path + }) + .or_else(|| { + let closed = spline.flags & 1 != 0; + // `through` emits one move, n-1 (open) or n (closed) cubics, and + // an additional close element for closed paths. Check before allocating + // its converted point vector. + let max_points = MAX_SPLINE_ELEMENTS.saturating_sub(if closed { 2 } else { 0 }); + if spline.fit.len() > max_points { + limit_hit = true; + return None; + } + through(&spline.fit.iter().map(|p| Point::new(p[0], p[1])).collect::>(), closed) + }); + if limit_hit { + return Err(Error::TooManySegments); + } + Ok(path) +} + +fn nurbs(degree: usize, knots: &[f64], control: &[[f64; 3]], weights: Option<&[f64]>, limit_hit: &mut bool) -> Option { + let n = control.len(); + if !(1..=MAX_SPLINE_DEGREE).contains(°ree) + || n <= degree + || knots.len() != n + degree + 1 + || knots.windows(2).any(|w| w[1] < w[0]) + || control.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) + || knots.iter().any(|k| !k.is_finite()) + { + return None; + } + let raw = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0); + let max_w = (0..n).map(raw).fold(0.0, f64::max); + if max_w == 0.0 { + return None; + } + let weight = |i: usize| (raw(i) / max_w).max(MIN_RELATIVE_WEIGHT); + let rational = (0..n).any(|i| (weight(i) - weight(0)).abs() > 1e-12); + let (lo, hi) = control.iter().fold((Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)), |(lo, hi), p| { + (Point::new(lo.x.min(p[0]), lo.y.min(p[1])), Point::new(hi.x.max(p[0]), hi.y.max(p[1]))) + }); + let tolerance = ((hi - lo).hypot() * SPLINE_TOLERANCE).max(1e-12); + let mut path = BezPath::new(); + for span in degree..n { + let (Some(&t0), Some(&t1)) = (knots.get(span), knots.get(span + 1)) else { continue }; + if t1 <= t0 { + continue; + } + let bezier: Vec = (0..=degree) + .map(|j| { + let args: Vec<_> = (0..degree).map(|k| if k < degree - j { t0 } else { t1 }).collect(); + blossom(degree, knots, control, span, &args, &weight) + }) + .collect::>()?; + let start = bezier.first()?.point()?; + let needs_new_subpath = path_endpoint(&path).is_none_or(|end| end.distance(start) > 1e-12 * (1.0 + start.to_vec2().hypot())); + if needs_new_subpath { + path.move_to(start); + } + // A rational degree-one span changes its parameterization with its weights, but its locus + // is still the straight segment between its endpoints. Preserve that exact geometry. + let polynomial: Option> = if rational && degree > 1 { None } else { bezier.iter().map(|p| p.point()).collect() }; + match polynomial.as_deref() { + Some([_, p]) => path.line_to(*p), + Some([_, q, p]) => path.quad_to(*q, *p), + Some([_, a, b, p]) => path.curve_to(*a, *b, *p), + _ => fit_spline(&mut path, &bezier, tolerance, 0, limit_hit)?, + } + if path.elements().len() > MAX_SPLINE_ELEMENTS { + *limit_hit = true; + return None; + } + } + (path.elements().len() > 1).then_some(path) +} + +fn path_endpoint(path: &BezPath) -> Option { + match path.elements().last()? { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => Some(*p), + kurbo::PathEl::QuadTo(_, p) => Some(*p), + kurbo::PathEl::CurveTo(_, _, p) => Some(*p), + kurbo::PathEl::ClosePath => None, + } +} + +fn blossom(degree: usize, knots: &[f64], control: &[[f64; 3]], span: usize, args: &[f64], weight: &impl Fn(usize) -> f64) -> Option { + // Only the active degree+1 control points are needed for one span. Convert this small window + // here instead of making a second vector proportional to an untrusted entity's full controls. + let first = span.checked_sub(degree)?; + let mut points: Vec<_> = control + .get(first..=span)? + .iter() + .enumerate() + .map(|(offset, p)| { + let w = weight(first + offset); + Homogeneous { x: p[0] * w, y: p[1] * w, w } + }) + .collect(); + for r in 1..=degree { + let u = *args.get(r - 1)?; + for j in (r..=degree).rev() { + let i = span - degree + j; + let (a, b) = (*knots.get(i)?, *knots.get(i + degree + 1 - r)?); + let alpha = if b - a > 1e-300 { (u - a) / (b - a) } else { 0.0 }; + let previous = *points.get(j - 1)?; + let current = points.get_mut(j)?; + *current = previous.lerp(*current, alpha); + } + } + points.get(degree).copied() +} + +fn eval_spline_bezier(bezier: &[Homogeneous], t: f64) -> Option<(Point, kurbo::Vec2)> { + let mut points = bezier.to_vec(); + let n = points.len().checked_sub(1)?; + for level in (1..=n).rev() { + if level == 1 { + let (a, b) = (*points.first()?, *points.get(1)?); + let p = a.lerp(b, t); + let point = p.point()?; + let k = n as f64; + let (dx, dy, dw) = (k * (b.x - a.x), k * (b.y - a.y), k * (b.w - a.w)); + let w2 = p.w * p.w; + return Some((point, kurbo::Vec2::new((dx * p.w - p.x * dw) / w2, (dy * p.w - p.y * dw) / w2))); + } + for i in 0..level { + let next = *points.get(i + 1)?; + let current = points.get_mut(i)?; + *current = current.lerp(next, t); + } + } + Some((points.first()?.point()?, kurbo::Vec2::ZERO)) +} + +fn fit_spline(path: &mut BezPath, bezier: &[Homogeneous], tolerance: f64, depth: u32, limit_hit: &mut bool) -> Option<()> { + let (p0, d0) = eval_spline_bezier(bezier, 0.0)?; + let (p3, d3) = eval_spline_bezier(bezier, 1.0)?; + let (c1, c2) = (p0 + d0 / 3.0, p3 - d3 / 3.0); + if ![c1, c2].iter().all(|p| p.x.is_finite() && p.y.is_finite()) { + path.line_to(p3); + return Some(()); + } + let candidate = [p0, c1, c2, p3]; + if cubic_error_bound(bezier, &candidate).is_some_and(|error| error <= tolerance) { + if path.elements().len() >= MAX_SPLINE_ELEMENTS { + *limit_hit = true; + return None; + } + path.curve_to(c1, c2, p3); + return Some(()); + } + if depth >= MAX_SPLINE_DEPTH || path.elements().len() >= MAX_SPLINE_ELEMENTS { + *limit_hit = true; + return None; + } + let (left, right) = split_homogeneous_bezier(bezier)?; + fit_spline(path, &left, tolerance, depth + 1, limit_hit)?; + fit_spline(path, &right, tolerance, depth + 1, limit_hit) +} + +fn split_homogeneous_bezier(bezier: &[Homogeneous]) -> Option<(Vec, Vec)> { + let count = bezier.len(); + if count < 2 { + return None; + } + let mut row = bezier.to_vec(); + let mut left = vec![*bezier.first()?; count]; + let mut right = vec![*bezier.last()?; count]; + left[0] = row[0]; + right[count - 1] = row[count - 1]; + for level in 1..count { + for index in 0..count - level { + row[index] = row[index].lerp(row[index + 1], 0.5); + } + left[level] = row[0]; + right[count - 1 - level] = row[count - 1 - level]; + } + Some((left, right)) +} + +// Bound the rational curve/cubic difference over the whole parameter interval. In Bernstein +// form, X(t)-W(t)C(t) has degree p+3, so its norm is bounded by the largest coefficient norm; +// W(t) is a convex combination of positive weights and is at least their minimum. +fn cubic_error_bound(bezier: &[Homogeneous], cubic: &[Point; 4]) -> Option { + let degree = bezier.len().checked_sub(1)?; + let total = degree.checked_add(3)?; + let min_weight = bezier.iter().map(|point| point.w).fold(f64::INFINITY, f64::min); + if !(min_weight > 0.0 && min_weight.is_finite()) { + return None; + } + let mut max_error_numerator: f64 = 0.0; + for k in 0..=total { + let mut x = 0.0; + let mut y = 0.0; + for (i, point) in bezier.iter().enumerate() { + let Some(j) = k.checked_sub(i).filter(|j| *j < 4) else { continue }; + let factor = binomial(degree, i) * binomial(3, j) / binomial(total, k); + x += (point.x - point.w * cubic[j].x) * factor; + y += (point.y - point.w * cubic[j].y) * factor; + } + max_error_numerator = max_error_numerator.max(x.hypot(y)); + } + Some(max_error_numerator / min_weight) +} + +fn binomial(n: usize, k: usize) -> f64 { + if k > n { + return 0.0; + } + let k = k.min(n - k); + (1..=k).fold(1.0, |value, i| value * (n + 1 - i) as f64 / i as f64) +} + +fn through(points: &[Point], closed: bool) -> Option { + let n = points.len(); + let first = *points.first()?; + if n < 2 { + return None; + } + let at = |i: isize| { + let len = n as isize; + let j = if closed { i.rem_euclid(len) } else { i.clamp(0, len - 1) }; + points.get(j as usize).copied().unwrap_or(first) + }; + let mut path = BezPath::new(); + path.move_to(first); + let segments = if closed { n } else { n - 1 }; + for i in 0..segments as isize { + let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); + path.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2); + } + if closed { + path.close_path(); + } + Some(path) +} + fn map_ocs_path(path: BezPath, normal: crate::model::Point, z: f64) -> BezPath { let [x, y, n] = crate::model::ocs_axes(normal); let transform = kurbo::Affine::new([x[0], x[1], y[0], y[1], n[0] * z, n[1] * z]); @@ -319,3 +598,262 @@ fn ellipse(center: crate::model::Point, major: crate::model::Point, normal: crat fn cross(a: crate::model::Point, b: crate::model::Point) -> crate::model::Point { [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] } + +#[cfg(test)] +mod spline_tests { + use super::*; + use crate::model::Spline; + use kurbo::ParamCurveNearest; + + // The previous importer accepted cubic fits based on seven fixed probes, then bisected the + // parameter interval. Keep this test-only reference to compare real legacy output with the + // conservative fit used by craft-dxf. + fn prior_fit(path: &mut BezPath, bezier: &[Homogeneous], t0: f64, t1: f64, tolerance: f64, depth: u32) -> Option<()> { + let (p0, d0) = eval_spline_bezier(bezier, t0)?; + let (p3, d3) = eval_spline_bezier(bezier, t1)?; + let h = (t1 - t0) / 3.0; + let (c1, c2) = (p0 + d0 * h, p3 - d3 * h); + if ![c1, c2].iter().all(|point| point.x.is_finite() && point.y.is_finite()) { + path.line_to(p3); + return Some(()); + } + let cubic = kurbo::CubicBez::new(p0, c1, c2, p3); + let close = (1..8).all(|i| { + let t = f64::from(i) / 8.0; + eval_spline_bezier(bezier, t0 + (t1 - t0) * t).is_some_and(|(point, _)| kurbo::ParamCurve::eval(&cubic, t).distance(point) <= tolerance) + }); + if close || depth >= MAX_SPLINE_DEPTH || path.elements().len() >= MAX_SPLINE_ELEMENTS { + path.curve_to(c1, c2, p3); + return Some(()); + } + let midpoint = (t0 + t1) / 2.0; + prior_fit(path, bezier, t0, midpoint, tolerance, depth + 1)?; + prior_fit(path, bezier, midpoint, t1, tolerance, depth + 1) + } + + fn legacy_path(bezier: &[Homogeneous], tolerance: f64) -> BezPath { + let mut path = BezPath::new(); + let start = eval_spline_bezier(bezier, 0.0).expect("legacy start point").0; + path.move_to(start); + prior_fit(&mut path, bezier, 0.0, 1.0, tolerance, 0).expect("legacy fit succeeds"); + path + } + + fn sampled_path_distance(from: &BezPath, to: &BezPath, tolerance: f64) -> f64 { + let target: Vec<_> = to.segments().collect(); + from.segments() + .flat_map(|segment| (0..=32).map(move |i| kurbo::ParamCurve::eval(&segment, f64::from(i) / 32.0))) + .map(|point| target.iter().map(|segment| segment.nearest(point, tolerance / 100.0).distance_sq.sqrt()).fold(f64::INFINITY, f64::min)) + .fold(0.0_f64, f64::max) + } + + fn cubic() -> Spline { + Spline { + degree: 3, + flags: 8, + knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], + control: vec![[0.0, 0.0, 0.0], [1.0, 2.0, 0.0], [2.0, 2.0, 0.0], [3.0, 0.0, 0.0]], + weights: Vec::new(), + fit: Vec::new(), + start_tangent: None, + end_tangent: None, + fit_tolerance: 0.0, + } + } + + fn xyz(points: &[Point]) -> Vec<[f64; 3]> { + points.iter().map(|point| [point.x, point.y, 0.0]).collect() + } + + #[test] + fn spline_path_follows_clamped_control_curve_and_bounds_work() { + let path = spline_path(&cubic()).unwrap().expect("valid cubic spline"); + assert_eq!(path.elements().len(), 2); + assert!(matches!(path.elements()[0], kurbo::PathEl::MoveTo(p) if p == Point::new(0.0, 0.0))); + assert!(matches!(path.elements()[1], kurbo::PathEl::CurveTo(a, b, p) + if a == Point::new(1.0, 2.0) && b == Point::new(2.0, 2.0) && p == Point::new(3.0, 0.0))); + assert_eq!(estimated_elements(&crate::model::EntityKind::Spline(cubic())), 0); + } + + #[test] + fn rational_closed_periodic_and_fit_point_splines_keep_compatibility() { + let mut rational_line = Spline { + degree: 1, + flags: 4, + knots: vec![0.0, 0.0, 1.0, 1.0], + control: vec![[2.0, 3.0, 0.0], [5.0, 7.0, 0.0]], + weights: vec![1.0, 100.0], + fit: Vec::new(), + start_tangent: None, + end_tangent: None, + fit_tolerance: 0.0, + }; + rational_line.flags |= 8; + let line = spline_path(&rational_line).unwrap().expect("rational degree-one spline"); + assert!(matches!(line.elements(), [kurbo::PathEl::MoveTo(a), kurbo::PathEl::LineTo(b)] + if *a == Point::new(2.0, 3.0) && *b == Point::new(5.0, 7.0))); + + let mut rational = cubic(); + rational.flags |= 4; + rational.weights = vec![1.0, 0.5, 0.5, 1.0]; + let rational_path = spline_path(&rational).unwrap().expect("rational spline"); + assert!(rational_path.elements().iter().any(|el| matches!(el, kurbo::PathEl::CurveTo(..)))); + + let mut closed = cubic(); + closed.flags |= 1; + assert!(matches!(spline_path(&closed).unwrap().unwrap().elements().last(), Some(kurbo::PathEl::ClosePath))); + let mut periodic = cubic(); + periodic.flags |= 2; + assert!(!matches!(spline_path(&periodic).unwrap().unwrap().elements().last(), Some(kurbo::PathEl::ClosePath))); + + let mut fit = cubic(); + fit.knots.clear(); + fit.fit = vec![[0.0, 0.0, 0.0], [1.0, 2.0, 0.0], [3.0, 0.0, 0.0]]; + let path = spline_path(&fit).unwrap().expect("fit-point fallback"); + assert!(matches!(path.elements()[0], kurbo::PathEl::MoveTo(p) if p == Point::new(0.0,0.0))); + assert!(matches!(path.elements().last(), Some(kurbo::PathEl::CurveTo(_, _, p)) if *p == Point::new(3.0,0.0))); + + let mut negative = cubic(); + negative.degree = -1; + negative.fit = fit.fit; + assert!(spline_path(&negative).unwrap().is_some(), "invalid degree reaches the prior fit-point fallback"); + } + + #[test] + fn spline_path_rejects_malformed_knots_and_defaults_invalid_weights() { + let mut spline = cubic(); + spline.knots[4] = f64::NAN; + assert!(spline_path(&spline).unwrap().is_none()); + let mut spline = cubic(); + spline.flags |= 4; + spline.weights = vec![1.0, 0.0, 1.0, 1.0]; + assert!(spline_path(&spline).unwrap().is_some(), "invalid weights historically fall back to unit weights"); + } + + #[test] + fn ascii_document_import_keeps_spline_as_supported_geometry() { + let bytes = b"0\nSECTION\n2\nENTITIES\n0\nSPLINE\n8\n0\n70\n8\n71\n3\n72\n8\n73\n4\n40\n0\n40\n0\n40\n0\n40\n0\n40\n1\n40\n1\n40\n1\n40\n1\n10\n0\n20\n0\n10\n1\n20\n2\n10\n2\n20\n2\n10\n3\n20\n0\n0\nENDSEC\n0\nEOF\n"; + let imported = import(bytes).expect("valid spline drawing"); + assert_eq!(imported.paths.len(), 1); + assert!(imported.warnings.is_empty()); + let bounds = imported.bounds.expect("spline bounds"); + assert!((bounds.x0 - 0.0).abs() < 1e-12 && (bounds.x1 - 3.0).abs() < 1e-12); + assert!((bounds.y0 - 0.0).abs() < 1e-12 && (bounds.y1 - 1.5).abs() < 1e-12); + } + + #[test] + fn discontinuous_nurbs_spans_remain_separate_subpaths() { + let mut limit_hit = false; + let path = nurbs( + 1, + &[0.0, 0.0, 0.5, 0.5, 1.0, 1.0], + &xyz(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(5.0, 5.0), Point::new(6.0, 5.0)]), + None, + &mut limit_hit, + ) + .expect("piecewise linear spline"); + assert!(!limit_hit); + assert!(matches!(path.elements(), [kurbo::PathEl::MoveTo(a), kurbo::PathEl::LineTo(b), kurbo::PathEl::MoveTo(c), kurbo::PathEl::LineTo(d)] + if *a == Point::new(0.0,0.0) && *b == Point::new(1.0,0.0) && *c == Point::new(5.0,5.0) && *d == Point::new(6.0,5.0))); + } + + #[test] + fn positive_knot_spans_at_large_offsets_are_kept() { + let start = 1.0e12; + let delta = 1.0e-3; + let mut limit_hit = false; + let path = nurbs(1, &[start, start, start + delta, start + delta], &xyz(&[Point::new(2.0, 3.0), Point::new(5.0, 7.0)]), None, &mut limit_hit) + .expect("positive-width span"); + assert!(!limit_hit); + assert!(matches!(path.elements(), [kurbo::PathEl::MoveTo(a), kurbo::PathEl::LineTo(b)] + if *a == Point::new(2.0, 3.0) && *b == Point::new(5.0, 7.0))); + } + + #[test] + fn fit_point_fallback_checks_element_budget_before_collecting() { + let mut spline = cubic(); + spline.degree = -1; + spline.fit = vec![[0.0, 0.0, 0.0]; MAX_SPLINE_ELEMENTS + 1]; + assert!(matches!(spline_path(&spline), Err(Error::TooManySegments))); + } + + #[test] + fn bernstein_error_bound_covers_rational_and_high_degree_deviations() { + for control in [ + vec![Homogeneous { x: 0.0, y: 0.0, w: 1.0 }, Homogeneous { x: 0.2, y: 2.0, w: 0.5 }, Homogeneous { x: 1.0, y: 0.0, w: 1.0 }], + vec![ + Homogeneous { x: 0.0, y: 0.0, w: 1.0 }, + Homogeneous { x: 0.2, y: 2.0, w: 0.7 }, + Homogeneous { x: 0.5, y: -2.0, w: 0.9 }, + Homogeneous { x: 0.8, y: 2.0, w: 1.2 }, + Homogeneous { x: 1.0, y: 0.0, w: 1.0 }, + ], + ] { + let degree = control.len() - 1; + let bezier = [Point::new(0.0, 0.0), Point::new(0.25, 1.0), Point::new(0.75, 1.0), Point::new(1.0, 0.0)]; + let bound = cubic_error_bound(&control, &bezier).unwrap(); + let cubic = kurbo::CubicBez::new(bezier[0], bezier[1], bezier[2], bezier[3]); + for sample in 0..=1024 { + let t = sample as f64 / 1024.0; + let mut points = control.clone(); + for level in (1..=degree).rev() { + for i in 0..level { + points[i] = points[i].lerp(points[i + 1], t); + } + } + let source = points[0].point().unwrap(); + let approximation = kurbo::ParamCurve::eval(&cubic, t); + assert!(source.distance(approximation) <= bound * (1.0 + 1e-12), "sample {t}: bound {bound}"); + } + } + } + + #[test] + fn rational_quadratic_circle_matches_the_prior_importer_tolerance() { + let weight = std::f64::consts::FRAC_1_SQRT_2; + let control = xyz(&[(1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (-1.0, 1.0), (-1.0, 0.0), (-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (1.0, 0.0)] + .map(|(x, y)| Point::new(200.0 + x * 100.0, 200.0 + y * 100.0))); + let knots = [0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 4.0]; + let weights = [1.0, weight, 1.0, weight, 1.0, weight, 1.0, weight, 1.0]; + let mut limit_hit = false; + let path = nurbs(2, &knots, &control, Some(&weights), &mut limit_hit).expect("circle NURBS"); + assert!(!limit_hit); + let center = Point::new(200.0, 200.0); + let max_radial_error = path + .segments() + .flat_map(|segment| (0..=32).map(move |i| kurbo::ParamCurve::eval(&segment, i as f64 / 32.0))) + .map(|point| (point.distance(center) - 100.0).abs()) + .fold(0.0_f64, f64::max); + assert!(max_radial_error < 0.01, "prior importer tolerance was 0.01; got {max_radial_error}"); + } + + #[test] + fn bounded_spline_fit_matches_prior_rational_and_high_degree_geometry() { + let weight = std::f64::consts::FRAC_1_SQRT_2; + let curves = [ + vec![Homogeneous { x: 1.0, y: 0.0, w: 1.0 }, Homogeneous { x: weight, y: weight, w: weight }, Homogeneous { x: 0.0, y: 1.0, w: 1.0 }], + vec![ + Homogeneous { x: 0.0, y: 0.0, w: 1.0 }, + Homogeneous { x: 0.2, y: 2.0, w: 0.7 }, + Homogeneous { x: 0.5, y: -2.0, w: 0.9 }, + Homogeneous { x: 0.8, y: 2.0, w: 1.2 }, + Homogeneous { x: 1.0, y: 0.0, w: 1.0 }, + ], + ]; + for bezier in curves { + let points: Vec<_> = bezier.iter().map(|point| point.point().expect("finite source point")).collect(); + let (lo, hi) = points.iter().fold((points[0], points[0]), |(lo, hi), point| { + (Point::new(lo.x.min(point.x), lo.y.min(point.y)), Point::new(hi.x.max(point.x), hi.y.max(point.y))) + }); + let tolerance = ((hi - lo).hypot() * SPLINE_TOLERANCE).max(1e-12); + let mut current = BezPath::new(); + current.move_to(points[0]); + let mut limit_hit = false; + fit_spline(&mut current, &bezier, tolerance, 0, &mut limit_hit).expect("bounded fit succeeds"); + assert!(!limit_hit); + let prior = legacy_path(&bezier, tolerance); + let difference = sampled_path_distance(&prior, ¤t, tolerance).max(sampled_path_distance(¤t, &prior, tolerance)); + assert!(difference <= 2.1 * tolerance, "legacy/new Hausdorff sample distance {difference} exceeds 2.1 × tolerance {tolerance}"); + } + } +}