Skip to main content

brepkit_math/
curves2d.rs

1//! 2D analytic curve types for parametric curves on surfaces (pcurves).
2//!
3//! These curves live in a surface's (u, v) parameter space and are used
4//! for exact surface trimming and boolean operations.
5
6use crate::MathError;
7use crate::vec::{Point2, Vec2};
8
9/// A 2D line defined by origin and direction.
10///
11/// Parameterized as `P(t) = origin + t * direction`.
12#[derive(Debug, Clone)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct Line2D {
15    origin: Point2,
16    direction: Vec2,
17}
18
19impl Line2D {
20    /// Creates a new 2D line.
21    ///
22    /// # Errors
23    /// Returns `MathError::ZeroVector` if direction has zero length.
24    pub fn new(origin: Point2, direction: Vec2) -> Result<Self, MathError> {
25        let len = direction.length();
26        if len < 1e-15 {
27            return Err(MathError::ZeroVector);
28        }
29        Ok(Self {
30            origin,
31            direction: Vec2::new(direction.x() / len, direction.y() / len),
32        })
33    }
34
35    /// Evaluates the line at parameter `t`.
36    #[must_use]
37    pub fn evaluate(&self, t: f64) -> Point2 {
38        self.origin + self.direction * t
39    }
40
41    /// Returns the (constant) tangent direction.
42    #[must_use]
43    pub const fn tangent(&self, _t: f64) -> Vec2 {
44        self.direction
45    }
46
47    /// Projects a point onto the line, returning the closest parameter.
48    #[must_use]
49    pub fn project(&self, point: Point2) -> f64 {
50        let v = point - self.origin;
51        v.dot(self.direction) / self.direction.length_squared()
52    }
53
54    /// Returns the perpendicular distance from a point to the line.
55    #[must_use]
56    pub fn distance_to_point(&self, point: Point2) -> f64 {
57        let t = self.project(point);
58        let closest = self.evaluate(t);
59        let diff = point - closest;
60        diff.length()
61    }
62
63    /// Returns the origin point.
64    #[must_use]
65    pub const fn origin(&self) -> Point2 {
66        self.origin
67    }
68
69    /// Returns the direction vector.
70    #[must_use]
71    pub const fn direction(&self) -> Vec2 {
72        self.direction
73    }
74}
75
76/// A 2D circle defined by center and radius.
77///
78/// Parameterized as `P(t) = center + radius * (cos(t), sin(t))`.
79/// `t` ranges from 0 to 2π for a full circle.
80#[derive(Debug, Clone)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82pub struct Circle2D {
83    center: Point2,
84    radius: f64,
85}
86
87impl Circle2D {
88    /// Creates a new 2D circle.
89    ///
90    /// # Errors
91    /// Returns `MathError::ParameterOutOfRange` if radius is not positive.
92    pub fn new(center: Point2, radius: f64) -> Result<Self, MathError> {
93        if radius <= 0.0 {
94            return Err(MathError::ParameterOutOfRange {
95                value: radius,
96                min: f64::EPSILON,
97                max: f64::MAX,
98            });
99        }
100        Ok(Self { center, radius })
101    }
102
103    /// Evaluates the circle at parameter `t` (radians).
104    #[must_use]
105    pub fn evaluate(&self, t: f64) -> Point2 {
106        self.center + Vec2::new(self.radius * t.cos(), self.radius * t.sin())
107    }
108
109    /// Returns the tangent vector at parameter `t`.
110    #[must_use]
111    pub fn tangent(&self, t: f64) -> Vec2 {
112        Vec2::new(-self.radius * t.sin(), self.radius * t.cos())
113    }
114
115    /// Returns the circumference.
116    #[must_use]
117    pub fn circumference(&self) -> f64 {
118        2.0 * std::f64::consts::PI * self.radius
119    }
120
121    /// Projects a point onto the circle, returning the closest parameter.
122    #[must_use]
123    pub fn project(&self, point: Point2) -> f64 {
124        let v = point - self.center;
125        v.y().atan2(v.x()).rem_euclid(2.0 * std::f64::consts::PI)
126    }
127
128    /// Returns the center.
129    #[must_use]
130    pub const fn center(&self) -> Point2 {
131        self.center
132    }
133
134    /// Returns the radius.
135    #[must_use]
136    pub const fn radius(&self) -> f64 {
137        self.radius
138    }
139}
140
141/// A 2D ellipse defined by center and two semi-axis lengths.
142///
143/// Parameterized as `P(t) = center + a*cos(t)*u + b*sin(t)*v`
144/// where `u = (cos(rotation), sin(rotation))` and `v = (-sin(rotation), cos(rotation))`.
145#[derive(Debug, Clone)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
147pub struct Ellipse2D {
148    center: Point2,
149    semi_major: f64,
150    semi_minor: f64,
151    rotation: f64,
152}
153
154impl Ellipse2D {
155    /// Creates a new 2D ellipse.
156    ///
157    /// # Errors
158    /// Returns `MathError::ParameterOutOfRange` if either semi-axis is not positive.
159    pub fn new(
160        center: Point2,
161        semi_major: f64,
162        semi_minor: f64,
163        rotation: f64,
164    ) -> Result<Self, MathError> {
165        if semi_major <= 0.0 {
166            return Err(MathError::ParameterOutOfRange {
167                value: semi_major,
168                min: f64::EPSILON,
169                max: f64::MAX,
170            });
171        }
172        if semi_minor <= 0.0 {
173            return Err(MathError::ParameterOutOfRange {
174                value: semi_minor,
175                min: f64::EPSILON,
176                max: f64::MAX,
177            });
178        }
179        if semi_minor > semi_major {
180            return Err(MathError::ParameterOutOfRange {
181                value: semi_minor,
182                min: 0.0,
183                max: semi_major,
184            });
185        }
186        Ok(Self {
187            center,
188            semi_major,
189            semi_minor,
190            rotation,
191        })
192    }
193
194    /// Evaluates the ellipse at parameter `t` (radians).
195    #[must_use]
196    pub fn evaluate(&self, t: f64) -> Point2 {
197        let (sin_r, cos_r) = self.rotation.sin_cos();
198        let x = self.semi_major * t.cos();
199        let y = self.semi_minor * t.sin();
200        self.center + Vec2::new(x.mul_add(cos_r, -(y * sin_r)), x.mul_add(sin_r, y * cos_r))
201    }
202
203    /// Returns the tangent vector at parameter `t`.
204    #[must_use]
205    pub fn tangent(&self, t: f64) -> Vec2 {
206        let (sin_r, cos_r) = self.rotation.sin_cos();
207        let dx = -self.semi_major * t.sin();
208        let dy = self.semi_minor * t.cos();
209        Vec2::new(
210            dx.mul_add(cos_r, -(dy * sin_r)),
211            dx.mul_add(sin_r, dy * cos_r),
212        )
213    }
214
215    /// Returns the center.
216    #[must_use]
217    pub const fn center(&self) -> Point2 {
218        self.center
219    }
220
221    /// Returns the semi-major axis length.
222    #[must_use]
223    pub const fn semi_major(&self) -> f64 {
224        self.semi_major
225    }
226
227    /// Returns the semi-minor axis length.
228    #[must_use]
229    pub const fn semi_minor(&self) -> f64 {
230        self.semi_minor
231    }
232
233    /// Returns the rotation angle.
234    #[must_use]
235    pub const fn rotation(&self) -> f64 {
236        self.rotation
237    }
238
239    /// Approximate circumference using Ramanujan's formula.
240    #[must_use]
241    pub fn approximate_circumference(&self) -> f64 {
242        let a = self.semi_major;
243        let b = self.semi_minor;
244        let h = ((a - b) * (a - b)) / ((a + b) * (a + b));
245        std::f64::consts::PI * (a + b) * (1.0 + 3.0 * h / (10.0 + 3.0f64.mul_add(-h, 4.0).sqrt()))
246    }
247}
248
249/// A 2D NURBS curve for parametric curves on surfaces.
250///
251/// This is the 2D analogue of `NurbsCurve`, used as pcurves
252/// in the surface parameter space.
253#[derive(Debug, Clone, PartialEq)]
254#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
255pub struct NurbsCurve2D {
256    degree: usize,
257    knots: Vec<f64>,
258    control_points: Vec<Point2>,
259    weights: Vec<f64>,
260}
261
262impl NurbsCurve2D {
263    /// Creates a new 2D NURBS curve.
264    ///
265    /// # Errors
266    /// Returns an error if knot vector or weights are inconsistent with
267    /// the number of control points and degree.
268    pub fn new(
269        degree: usize,
270        knots: Vec<f64>,
271        control_points: Vec<Point2>,
272        weights: Vec<f64>,
273    ) -> Result<Self, MathError> {
274        let n = control_points.len();
275        let expected_knots = n + degree + 1;
276        if knots.len() != expected_knots {
277            return Err(MathError::InvalidKnotVector {
278                expected: expected_knots,
279                got: knots.len(),
280            });
281        }
282        if weights.len() != n {
283            return Err(MathError::InvalidWeights {
284                expected: n,
285                got: weights.len(),
286            });
287        }
288        if n == 0 {
289            return Err(MathError::EmptyInput);
290        }
291        Ok(Self {
292            degree,
293            knots,
294            control_points,
295            weights,
296        })
297    }
298
299    /// Evaluates the curve at parameter `u` using De Boor's algorithm.
300    #[must_use]
301    pub fn evaluate(&self, u: f64) -> Point2 {
302        let n = self.control_points.len();
303        let p = self.degree;
304        let span = crate::nurbs::basis::find_span(n, p, u, &self.knots);
305        let mut basis_stack = [0.0f64; crate::nurbs::basis::MAX_STACK_OUTPUT + 1];
306        let mut basis_heap = Vec::new();
307        let basis = crate::nurbs::basis::scratch(&mut basis_stack, &mut basis_heap, p + 1);
308        crate::nurbs::basis::basis_funs_into(span, u, p, &self.knots, basis);
309
310        let mut wx = 0.0;
311        let mut wy = 0.0;
312        let mut ww = 0.0;
313
314        for (j, &basis_val) in basis.iter().enumerate().take(p + 1) {
315            let idx = span - p + j;
316            let cp = &self.control_points[idx];
317            let w = self.weights[idx];
318            let bw = basis_val * w;
319            wx += bw * cp.x();
320            wy += bw * cp.y();
321            ww += bw;
322        }
323
324        if ww.abs() < f64::EPSILON {
325            return self.control_points[0];
326        }
327        Point2::new(wx / ww, wy / ww)
328    }
329
330    /// Returns the first derivative at parameter `u`.
331    #[must_use]
332    #[allow(clippy::many_single_char_names)]
333    pub fn tangent(&self, param: f64) -> Vec2 {
334        let num_pts = self.control_points.len();
335        let deg = self.degree;
336        let span = crate::nurbs::basis::find_span(num_pts, deg, param, &self.knots);
337        let stride = deg + 1;
338        let mut ders_stack = [0.0f64; 2 * (crate::nurbs::basis::MAX_STACK_OUTPUT + 1)];
339        let mut ders_heap = Vec::new();
340        let ders_buf = crate::nurbs::basis::scratch(&mut ders_stack, &mut ders_heap, 2 * stride);
341        crate::nurbs::basis::ders_basis_funs_into(span, param, deg, 1, &self.knots, ders_buf);
342
343        let mut curve_pt = Vec2::new(0.0, 0.0);
344        let mut curve_deriv = Vec2::new(0.0, 0.0);
345        let mut weight_sum = 0.0;
346        let mut weight_deriv = 0.0;
347
348        for j in 0..=deg {
349            let basis_val = ders_buf[j];
350            let basis_deriv = ders_buf[stride + j];
351            let idx = span - deg + j;
352            let wi = self.weights[idx];
353            let cp = &self.control_points[idx];
354            let cp_vec = Vec2::new(cp.x(), cp.y());
355
356            curve_pt += cp_vec * (wi * basis_val);
357            curve_deriv += cp_vec * (wi * basis_deriv);
358            weight_sum += wi * basis_val;
359            weight_deriv += wi * basis_deriv;
360        }
361
362        if weight_sum.abs() < f64::EPSILON {
363            return Vec2::new(0.0, 0.0);
364        }
365        (curve_deriv * weight_sum - curve_pt * weight_deriv) * (1.0 / (weight_sum * weight_sum))
366    }
367
368    /// Returns the parameter domain `(u_min, u_max)`.
369    #[must_use]
370    pub fn domain(&self) -> (f64, f64) {
371        (
372            self.knots[self.degree],
373            self.knots[self.knots.len() - self.degree - 1],
374        )
375    }
376
377    /// Returns the degree.
378    #[must_use]
379    pub const fn degree(&self) -> usize {
380        self.degree
381    }
382
383    /// Returns a reference to the knot vector.
384    #[must_use]
385    pub fn knots(&self) -> &[f64] {
386        &self.knots
387    }
388
389    /// Returns a reference to the control points.
390    #[must_use]
391    pub fn control_points(&self) -> &[Point2] {
392        &self.control_points
393    }
394
395    /// Returns a reference to the weights.
396    #[must_use]
397    pub fn weights(&self) -> &[f64] {
398        &self.weights
399    }
400
401    /// Returns true if the curve is rational (any weight != 1.0).
402    #[must_use]
403    pub fn is_rational(&self) -> bool {
404        self.weights.iter().any(|w| (*w - 1.0).abs() > f64::EPSILON)
405    }
406
407    /// Creates a 2D line segment as a NURBS curve (degree 1).
408    ///
409    /// # Errors
410    /// Returns `MathError::EmptyInput` if called with invalid parameters (should not happen).
411    pub fn from_line(start: Point2, end: Point2) -> Result<Self, MathError> {
412        Self::new(
413            1,
414            vec![0.0, 0.0, 1.0, 1.0],
415            vec![start, end],
416            vec![1.0, 1.0],
417        )
418    }
419}
420
421/// Unified enum for 2D curve types used as pcurves.
422#[derive(Debug, Clone)]
423#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
424pub enum Curve2D {
425    /// A straight line in parameter space.
426    Line(Line2D),
427    /// A circle in parameter space.
428    Circle(Circle2D),
429    /// An ellipse in parameter space.
430    Ellipse(Ellipse2D),
431    /// A NURBS curve in parameter space.
432    Nurbs(NurbsCurve2D),
433}
434
435impl Curve2D {
436    /// Evaluates the curve at parameter `t`.
437    #[must_use]
438    pub fn evaluate(&self, t: f64) -> Point2 {
439        match self {
440            Self::Line(c) => c.evaluate(t),
441            Self::Circle(c) => c.evaluate(t),
442            Self::Ellipse(c) => c.evaluate(t),
443            Self::Nurbs(c) => c.evaluate(t),
444        }
445    }
446
447    /// Returns the tangent at parameter `t`.
448    #[must_use]
449    pub fn tangent(&self, t: f64) -> Vec2 {
450        match self {
451            Self::Line(c) => c.tangent(t),
452            Self::Circle(c) => c.tangent(t),
453            Self::Ellipse(c) => c.tangent(t),
454            Self::Nurbs(c) => c.tangent(t),
455        }
456    }
457}
458
459#[cfg(test)]
460#[allow(clippy::unwrap_used, clippy::expect_used)]
461mod tests {
462    use super::*;
463    use std::f64::consts::PI;
464
465    const TOL: f64 = 1e-10;
466
467    fn approx_eq(a: f64, b: f64) -> bool {
468        (a - b).abs() < TOL
469    }
470
471    fn point2_approx_eq(a: Point2, b: Point2) -> bool {
472        approx_eq(a.x(), b.x()) && approx_eq(a.y(), b.y())
473    }
474
475    // ── Line2D tests ──────────────────────────────────
476
477    #[test]
478    fn line2d_evaluate() {
479        // Direction is normalized, so parameter t is arc-length
480        let line = Line2D::new(Point2::new(1.0, 2.0), Vec2::new(3.0, 4.0)).expect("valid line");
481
482        let p = line.evaluate(0.0);
483        assert!(point2_approx_eq(p, Point2::new(1.0, 2.0)));
484
485        // direction normalized to (0.6, 0.8), so at t=5 we get (1+3, 2+4) = (4, 6)
486        let p = line.evaluate(5.0);
487        assert!(point2_approx_eq(p, Point2::new(4.0, 6.0)));
488
489        let p = line.evaluate(2.5);
490        assert!(point2_approx_eq(p, Point2::new(2.5, 4.0)));
491    }
492
493    #[test]
494    fn line2d_project() {
495        let line = Line2D::new(Point2::new(0.0, 0.0), Vec2::new(1.0, 0.0)).expect("valid line");
496
497        assert!(approx_eq(line.project(Point2::new(5.0, 0.0)), 5.0));
498        assert!(approx_eq(line.project(Point2::new(3.0, 7.0)), 3.0));
499    }
500
501    #[test]
502    fn line2d_distance() {
503        let line = Line2D::new(Point2::new(0.0, 0.0), Vec2::new(1.0, 0.0)).expect("valid line");
504        assert!(approx_eq(
505            line.distance_to_point(Point2::new(3.0, 4.0)),
506            4.0
507        ));
508    }
509
510    #[test]
511    fn line2d_zero_direction_error() {
512        let result = Line2D::new(Point2::new(0.0, 0.0), Vec2::new(0.0, 0.0));
513        assert!(result.is_err());
514    }
515
516    // ── Circle2D tests ────────────────────────────────
517
518    #[test]
519    fn circle2d_evaluate_at_zero() {
520        let circle = Circle2D::new(Point2::new(0.0, 0.0), 1.0).expect("valid circle");
521        let p = circle.evaluate(0.0);
522        assert!(point2_approx_eq(p, Point2::new(1.0, 0.0)));
523    }
524
525    #[test]
526    fn circle2d_evaluate_quarter() {
527        let circle = Circle2D::new(Point2::new(0.0, 0.0), 2.0).expect("valid circle");
528        let p = circle.evaluate(PI / 2.0);
529        assert!(point2_approx_eq(p, Point2::new(0.0, 2.0)));
530    }
531
532    #[test]
533    fn circle2d_circumference() {
534        let circle = Circle2D::new(Point2::new(0.0, 0.0), 3.0).expect("valid circle");
535        assert!(approx_eq(circle.circumference(), 6.0 * PI));
536    }
537
538    #[test]
539    fn circle2d_project_roundtrip() {
540        let circle = Circle2D::new(Point2::new(1.0, 1.0), 5.0).expect("valid circle");
541        let t = 1.234;
542        let point = circle.evaluate(t);
543        let t_proj = circle.project(point);
544        assert!(approx_eq(t, t_proj));
545    }
546
547    #[test]
548    fn circle2d_zero_radius_error() {
549        let result = Circle2D::new(Point2::new(0.0, 0.0), 0.0);
550        assert!(result.is_err());
551    }
552
553    // ── Ellipse2D tests ───────────────────────────────
554
555    #[test]
556    fn ellipse2d_evaluate_no_rotation() {
557        let ellipse = Ellipse2D::new(Point2::new(0.0, 0.0), 3.0, 2.0, 0.0).expect("valid ellipse");
558
559        let p = ellipse.evaluate(0.0);
560        assert!(point2_approx_eq(p, Point2::new(3.0, 0.0)));
561
562        let p = ellipse.evaluate(PI / 2.0);
563        assert!(point2_approx_eq(p, Point2::new(0.0, 2.0)));
564    }
565
566    #[test]
567    fn ellipse2d_evaluate_with_rotation() {
568        let ellipse =
569            Ellipse2D::new(Point2::new(0.0, 0.0), 3.0, 2.0, PI / 2.0).expect("valid ellipse");
570        let p = ellipse.evaluate(0.0);
571        assert!(point2_approx_eq(p, Point2::new(0.0, 3.0)));
572    }
573
574    #[test]
575    fn ellipse2d_circle_circumference() {
576        let ellipse = Ellipse2D::new(Point2::new(0.0, 0.0), 5.0, 5.0, 0.0).expect("valid ellipse");
577        assert!(approx_eq(
578            ellipse.approximate_circumference(),
579            2.0 * PI * 5.0
580        ));
581    }
582
583    #[test]
584    fn ellipse2d_zero_axis_error() {
585        assert!(Ellipse2D::new(Point2::new(0.0, 0.0), 0.0, 1.0, 0.0).is_err());
586        assert!(Ellipse2D::new(Point2::new(0.0, 0.0), 1.0, 0.0, 0.0).is_err());
587    }
588
589    // ── NurbsCurve2D tests ────────────────────────────
590
591    #[test]
592    fn nurbs2d_line_segment() {
593        let curve =
594            NurbsCurve2D::from_line(Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)).expect("valid");
595
596        assert!(point2_approx_eq(curve.evaluate(0.0), Point2::new(0.0, 0.0)));
597        assert!(point2_approx_eq(curve.evaluate(1.0), Point2::new(1.0, 1.0)));
598        assert!(point2_approx_eq(curve.evaluate(0.5), Point2::new(0.5, 0.5)));
599    }
600
601    #[test]
602    fn nurbs2d_quadratic_bezier() {
603        let curve = NurbsCurve2D::new(
604            2,
605            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
606            vec![
607                Point2::new(0.0, 0.0),
608                Point2::new(0.5, 1.0),
609                Point2::new(1.0, 0.0),
610            ],
611            vec![1.0, 1.0, 1.0],
612        )
613        .expect("valid curve");
614
615        assert!(point2_approx_eq(curve.evaluate(0.0), Point2::new(0.0, 0.0)));
616        assert!(point2_approx_eq(curve.evaluate(1.0), Point2::new(1.0, 0.0)));
617        assert!(point2_approx_eq(curve.evaluate(0.5), Point2::new(0.5, 0.5)));
618    }
619
620    #[test]
621    fn nurbs2d_tangent() {
622        let curve =
623            NurbsCurve2D::from_line(Point2::new(0.0, 0.0), Point2::new(2.0, 3.0)).expect("valid");
624        let tangent = curve.tangent(0.5);
625        assert!(approx_eq(tangent.x(), 2.0));
626        assert!(approx_eq(tangent.y(), 3.0));
627    }
628
629    #[test]
630    fn nurbs2d_domain() {
631        let curve = NurbsCurve2D::new(
632            2,
633            vec![0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0],
634            vec![
635                Point2::new(0.0, 0.0),
636                Point2::new(0.25, 1.0),
637                Point2::new(0.75, 1.0),
638                Point2::new(1.0, 0.0),
639            ],
640            vec![1.0, 1.0, 1.0, 1.0],
641        )
642        .expect("valid curve");
643
644        let (u_min, u_max) = curve.domain();
645        assert!(approx_eq(u_min, 0.0));
646        assert!(approx_eq(u_max, 1.0));
647    }
648
649    #[test]
650    fn nurbs2d_invalid_knots() {
651        let result = NurbsCurve2D::new(
652            1,
653            vec![0.0, 1.0],
654            vec![Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)],
655            vec![1.0, 1.0],
656        );
657        assert!(result.is_err());
658    }
659
660    #[test]
661    fn nurbs2d_invalid_weights() {
662        let result = NurbsCurve2D::new(
663            1,
664            vec![0.0, 0.0, 1.0, 1.0],
665            vec![Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)],
666            vec![1.0],
667        );
668        assert!(result.is_err());
669    }
670
671    #[test]
672    fn nurbs2d_is_rational() {
673        let non_rational =
674            NurbsCurve2D::from_line(Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)).expect("valid");
675        assert!(!non_rational.is_rational());
676
677        let rational = NurbsCurve2D::new(
678            1,
679            vec![0.0, 0.0, 1.0, 1.0],
680            vec![Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)],
681            vec![1.0, 2.0],
682        )
683        .expect("valid");
684        assert!(rational.is_rational());
685    }
686
687    // ── Curve2D enum tests ────────────────────────────
688
689    #[test]
690    fn curve2d_evaluate_dispatch() {
691        let line =
692            Curve2D::Line(Line2D::new(Point2::new(0.0, 0.0), Vec2::new(1.0, 0.0)).expect("valid"));
693        assert!(point2_approx_eq(line.evaluate(5.0), Point2::new(5.0, 0.0)));
694
695        let circle = Curve2D::Circle(Circle2D::new(Point2::new(0.0, 0.0), 1.0).expect("valid"));
696        assert!(point2_approx_eq(
697            circle.evaluate(0.0),
698            Point2::new(1.0, 0.0)
699        ));
700    }
701
702    // ── Ellipse2D additional tests ────────────────────
703
704    #[test]
705    fn ellipse2d_tangent_no_rotation() {
706        // At t=0, no rotation: tangent = (dx*cos_r - dy*sin_r, dx*sin_r + dy*cos_r)
707        // dx = -a*sin(0) = 0, dy = b*cos(0) = b, rotation=0 → tangent = (0, b)
708        let ellipse = Ellipse2D::new(Point2::new(0.0, 0.0), 3.0, 2.0, 0.0).expect("valid ellipse");
709        let tang = ellipse.tangent(0.0);
710        assert!(approx_eq(tang.x(), 0.0));
711        assert!(approx_eq(tang.y(), 2.0));
712    }
713
714    #[test]
715    fn ellipse2d_tangent_at_quarter() {
716        // At t=π/2, no rotation: dx = -a*sin(π/2) = -a, dy = b*cos(π/2) = 0
717        // tangent = (-a, 0)
718        let ellipse = Ellipse2D::new(Point2::new(0.0, 0.0), 3.0, 2.0, 0.0).expect("valid ellipse");
719        let tang = ellipse.tangent(PI / 2.0);
720        assert!(approx_eq(tang.x(), -3.0));
721        assert!(approx_eq(tang.y(), 0.0));
722    }
723
724    #[test]
725    fn ellipse2d_minor_exceeds_major_error() {
726        // semi_minor > semi_major must be rejected
727        assert!(Ellipse2D::new(Point2::new(0.0, 0.0), 2.0, 5.0, 0.0).is_err());
728    }
729
730    // ── Circle2D tangent tests ────────────────────────
731
732    #[test]
733    fn circle2d_tangent_at_zero() {
734        // Tangent at t=0: (-r*sin(0), r*cos(0)) = (0, r)
735        let circle = Circle2D::new(Point2::new(0.0, 0.0), 3.0).expect("valid circle");
736        let tang = circle.tangent(0.0);
737        assert!(approx_eq(tang.x(), 0.0));
738        assert!(approx_eq(tang.y(), 3.0));
739    }
740
741    #[test]
742    fn circle2d_tangent_at_quarter() {
743        // Tangent at t=π/2: (-r*sin(π/2), r*cos(π/2)) = (-r, 0)
744        let circle = Circle2D::new(Point2::new(0.0, 0.0), 2.0).expect("valid circle");
745        let tang = circle.tangent(PI / 2.0);
746        assert!(approx_eq(tang.x(), -2.0));
747        assert!(approx_eq(tang.y(), 0.0));
748    }
749
750    #[test]
751    fn circle2d_tangent_perpendicular_to_radius() {
752        // The tangent should always be perpendicular to the radius direction
753        let circle = Circle2D::new(Point2::new(1.0, 2.0), 4.0).expect("valid circle");
754        for i in 0..8 {
755            let t = f64::from(i) * PI / 4.0;
756            let pt = circle.evaluate(t);
757            let tang = circle.tangent(t);
758            let radius_vec_x = pt.x() - circle.center().x();
759            let radius_vec_y = pt.y() - circle.center().y();
760            let dot = radius_vec_x * tang.x() + radius_vec_y * tang.y();
761            assert!(
762                approx_eq(dot, 0.0),
763                "tangent not perpendicular to radius at t={t}: dot={dot}"
764            );
765        }
766    }
767
768    // ── NurbsCurve2D additional tests ─────────────────
769
770    #[test]
771    fn nurbs2d_tangent_quadratic_bezier() {
772        // Quadratic Bezier: P0=(0,0), P1=(0.5,1), P2=(1,0)
773        // Endpoint tangents: B'(0) = 2*(P1-P0) = (1, 2), B'(1) = 2*(P2-P1) = (1, -2)
774        let curve = NurbsCurve2D::new(
775            2,
776            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
777            vec![
778                Point2::new(0.0, 0.0),
779                Point2::new(0.5, 1.0),
780                Point2::new(1.0, 0.0),
781            ],
782            vec![1.0, 1.0, 1.0],
783        )
784        .expect("valid curve");
785
786        let tang_start = curve.tangent(0.0);
787        assert!(
788            (tang_start.x() - 1.0).abs() < 1e-8,
789            "start tangent x: {}",
790            tang_start.x()
791        );
792        assert!(
793            (tang_start.y() - 2.0).abs() < 1e-8,
794            "start tangent y: {}",
795            tang_start.y()
796        );
797
798        let tang_end = curve.tangent(1.0);
799        assert!(
800            (tang_end.x() - 1.0).abs() < 1e-8,
801            "end tangent x: {}",
802            tang_end.x()
803        );
804        assert!(
805            (tang_end.y() + 2.0).abs() < 1e-8,
806            "end tangent y: {}",
807            tang_end.y()
808        );
809    }
810
811    #[test]
812    fn nurbs2d_tangent_midpoint_is_horizontal() {
813        // Symmetric arch P0=(0,0), P1=(0.5,1), P2=(1,0) — tangent at t=0.5 has zero y-component
814        let curve = NurbsCurve2D::new(
815            2,
816            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
817            vec![
818                Point2::new(0.0, 0.0),
819                Point2::new(0.5, 1.0),
820                Point2::new(1.0, 0.0),
821            ],
822            vec![1.0, 1.0, 1.0],
823        )
824        .expect("valid curve");
825
826        let tang = curve.tangent(0.5);
827        assert!(
828            tang.y().abs() < 1e-10,
829            "midpoint tangent y should be zero, got {}",
830            tang.y()
831        );
832        assert!(tang.x() > 0.0, "midpoint tangent x should be positive");
833    }
834
835    #[test]
836    fn nurbs2d_empty_input_error() {
837        // degree=0, expected_knots = 0+0+1 = 1; provide 1 knot, 0 control points, 0 weights.
838        // The weights length check passes (both 0), then the EmptyInput guard fires.
839        let result = NurbsCurve2D::new(0, vec![0.0], vec![], vec![]);
840        assert!(
841            matches!(result, Err(MathError::EmptyInput)),
842            "expected EmptyInput, got {result:?}"
843        );
844    }
845
846    #[test]
847    fn curve2d_tangent_dispatch_all_variants() {
848        // Line: constant direction (1, 0)
849        let line =
850            Curve2D::Line(Line2D::new(Point2::new(0.0, 0.0), Vec2::new(1.0, 0.0)).expect("valid"));
851        let tang = line.tangent(7.0);
852        assert!(approx_eq(tang.x(), 1.0));
853        assert!(approx_eq(tang.y(), 0.0));
854
855        // Circle (r=1): tangent at t=0 is (0, 1)
856        let circle = Curve2D::Circle(Circle2D::new(Point2::new(0.0, 0.0), 1.0).expect("valid"));
857        let tang = circle.tangent(0.0);
858        assert!(approx_eq(tang.x(), 0.0));
859        assert!(approx_eq(tang.y(), 1.0));
860
861        // Ellipse (a=4, b=2, rotation=0): tangent at t=0 is (0, b) = (0, 2)
862        let ellipse =
863            Curve2D::Ellipse(Ellipse2D::new(Point2::new(0.0, 0.0), 4.0, 2.0, 0.0).expect("valid"));
864        let tang = ellipse.tangent(0.0);
865        assert!(approx_eq(tang.x(), 0.0));
866        assert!(approx_eq(tang.y(), 2.0));
867
868        // NURBS line segment (0,0)→(2,3): tangent = (2, 3)
869        let nurbs = Curve2D::Nurbs(
870            NurbsCurve2D::from_line(Point2::new(0.0, 0.0), Point2::new(2.0, 3.0)).expect("valid"),
871        );
872        let tang = nurbs.tangent(0.5);
873        assert!(approx_eq(tang.x(), 2.0));
874        assert!(approx_eq(tang.y(), 3.0));
875    }
876}