diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs index d60d91456..455fcec43 100644 --- a/crates/dxf/src/paths.rs +++ b/crates/dxf/src/paths.rs @@ -128,6 +128,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, } } @@ -187,10 +190,188 @@ fn entity_paths(kind: &crate::model::EntityKind, normal: crate::model::Point) -> E::Ellipse { center, major, ratio, start, end } if ratio.is_finite() && *ratio > 0.0 => { vec![ellipse(*center, *major, normal, *ratio, *start, *end)] } + E::Spline(spline) => spline_path(spline).into_iter().collect(), _ => Vec::new(), } } +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) -> Option { + let degree = usize::try_from(spline.degree).ok()?; + let points: Vec = spline.control.iter().map(|p| Point::new(p[0], p[1])).collect(); + let weights = (spline.flags & 4 != 0 && spline.weights.len() == points.len()).then_some(spline.weights.as_slice()); + nurbs(degree, &spline.knots, &points, weights) + .map(|mut path| { + if spline.flags & 1 != 0 { + path.close_path(); + } + path + }) + .or_else(|| through(&spline.fit.iter().map(|p| Point::new(p[0], p[1])).collect::>(), spline.flags & 1 != 0)) +} + +fn nurbs(degree: usize, knots: &[f64], control: &[Point], weights: Option<&[f64]>) -> 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.x.is_finite() || !p.y.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 homogeneous: Vec<_> = control.iter().enumerate().map(|(i, p)| Homogeneous { x: p.x * weight(i), y: p.y * weight(i), w: weight(i) }).collect(); + 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.x), lo.y.min(p.y)), Point::new(hi.x.max(p.x), hi.y.max(p.y))) + }); + 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 <= 1e-12 * (1.0 + t0.abs()) { + 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, &homogeneous, span, &args) + }) + .collect::>()?; + if path.elements().is_empty() { + path.move_to(bezier.first()?.point()?); + } + let polynomial: Option> = if rational { 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, 0.0, 1.0, tolerance, 0)?, + } + if path.elements().len() > MAX_SPLINE_ELEMENTS { + return None; + } + } + (path.elements().len() > 1).then_some(path) +} + +fn blossom(degree: usize, knots: &[f64], control: &[Homogeneous], span: usize, args: &[f64]) -> Option { + let mut points = control.get(span - degree..=span)?.to_vec(); + 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], 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(|p| p.x.is_finite() && p.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 s = f64::from(i) / 8.0; + eval_spline_bezier(bezier, t0 + (t1 - t0) * s).is_some_and(|(p, _)| kurbo::ParamCurve::eval(&cubic, s).distance(p) <= tolerance) + }); + if close || depth >= MAX_SPLINE_DEPTH || path.elements().len() >= MAX_SPLINE_ELEMENTS { + path.curve_to(c1, c2, p3); + return Some(()); + } + let mid = (t0 + t1) / 2.0; + fit_spline(path, bezier, t0, mid, tolerance, depth + 1)?; + fit_spline(path, bezier, mid, t1, tolerance, depth + 1) +} + +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 +500,78 @@ 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; + + 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, + } + } + + #[test] + fn spline_path_follows_clamped_control_curve_and_bounds_work() { + let path = spline_path(&cubic()).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 = cubic(); + rational.flags |= 4; + rational.weights = vec![1.0, 0.5, 0.5, 1.0]; + let rational_path = spline_path(&rational).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().elements().last(), Some(kurbo::PathEl::ClosePath))); + let mut periodic = cubic(); + periodic.flags |= 2; + assert!(!matches!(spline_path(&periodic).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).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))); + } + + #[test] + fn spline_path_rejects_malformed_knots_and_defaults_invalid_weights() { + let mut spline = cubic(); + spline.knots[4] = f64::NAN; + assert!(spline_path(&spline).is_none()); + let mut spline = cubic(); + spline.flags |= 4; + spline.weights = vec![1.0, 0.0, 1.0, 1.0]; + assert!(spline_path(&spline).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); + } +}