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 = [0.0f64; crate::nurbs::basis::MAX_STACK_OUTPUT + 1];
306        crate::nurbs::basis::basis_funs_into(span, u, p, &self.knots, &mut basis[..=p]);
307
308        let mut wx = 0.0;
309        let mut wy = 0.0;
310        let mut ww = 0.0;
311
312        for (j, &basis_val) in basis.iter().enumerate().take(p + 1) {
313            let idx = span - p + j;
314            let cp = &self.control_points[idx];
315            let w = self.weights[idx];
316            let bw = basis_val * w;
317            wx += bw * cp.x();
318            wy += bw * cp.y();
319            ww += bw;
320        }
321
322        if ww.abs() < f64::EPSILON {
323            return self.control_points[0];
324        }
325        Point2::new(wx / ww, wy / ww)
326    }
327
328    /// Returns the first derivative at parameter `u`.
329    #[must_use]
330    #[allow(clippy::many_single_char_names)]
331    pub fn tangent(&self, param: f64) -> Vec2 {
332        let num_pts = self.control_points.len();
333        let deg = self.degree;
334        let span = crate::nurbs::basis::find_span(num_pts, deg, param, &self.knots);
335        let stride = deg + 1;
336        let mut ders_buf = [0.0f64; 2 * (crate::nurbs::basis::MAX_STACK_OUTPUT + 1)];
337        crate::nurbs::basis::ders_basis_funs_into(
338            span,
339            param,
340            deg,
341            1,
342            &self.knots,
343            &mut ders_buf[..2 * stride],
344        );
345
346        let mut curve_pt = Vec2::new(0.0, 0.0);
347        let mut curve_deriv = Vec2::new(0.0, 0.0);
348        let mut weight_sum = 0.0;
349        let mut weight_deriv = 0.0;
350
351        for j in 0..=deg {
352            let basis_val = ders_buf[j];
353            let basis_deriv = ders_buf[stride + j];
354            let idx = span - deg + j;
355            let wi = self.weights[idx];
356            let cp = &self.control_points[idx];
357            let cp_vec = Vec2::new(cp.x(), cp.y());
358
359            curve_pt += cp_vec * (wi * basis_val);
360            curve_deriv += cp_vec * (wi * basis_deriv);
361            weight_sum += wi * basis_val;
362            weight_deriv += wi * basis_deriv;
363        }
364
365        if weight_sum.abs() < f64::EPSILON {
366            return Vec2::new(0.0, 0.0);
367        }
368        (curve_deriv * weight_sum - curve_pt * weight_deriv) * (1.0 / (weight_sum * weight_sum))
369    }
370
371    /// Returns the parameter domain `(u_min, u_max)`.
372    #[must_use]
373    pub fn domain(&self) -> (f64, f64) {
374        (
375            self.knots[self.degree],
376            self.knots[self.knots.len() - self.degree - 1],
377        )
378    }
379
380    /// Returns the degree.
381    #[must_use]
382    pub const fn degree(&self) -> usize {
383        self.degree
384    }
385
386    /// Returns a reference to the knot vector.
387    #[must_use]
388    pub fn knots(&self) -> &[f64] {
389        &self.knots
390    }
391
392    /// Returns a reference to the control points.
393    #[must_use]
394    pub fn control_points(&self) -> &[Point2] {
395        &self.control_points
396    }
397
398    /// Returns a reference to the weights.
399    #[must_use]
400    pub fn weights(&self) -> &[f64] {
401        &self.weights
402    }
403
404    /// Returns true if the curve is rational (any weight != 1.0).
405    #[must_use]
406    pub fn is_rational(&self) -> bool {
407        self.weights.iter().any(|w| (*w - 1.0).abs() > f64::EPSILON)
408    }
409
410    /// Creates a 2D line segment as a NURBS curve (degree 1).
411    ///
412    /// # Errors
413    /// Returns `MathError::EmptyInput` if called with invalid parameters (should not happen).
414    pub fn from_line(start: Point2, end: Point2) -> Result<Self, MathError> {
415        Self::new(
416            1,
417            vec![0.0, 0.0, 1.0, 1.0],
418            vec![start, end],
419            vec![1.0, 1.0],
420        )
421    }
422}
423
424/// Unified enum for 2D curve types used as pcurves.
425#[derive(Debug, Clone)]
426#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
427pub enum Curve2D {
428    /// A straight line in parameter space.
429    Line(Line2D),
430    /// A circle in parameter space.
431    Circle(Circle2D),
432    /// An ellipse in parameter space.
433    Ellipse(Ellipse2D),
434    /// A NURBS curve in parameter space.
435    Nurbs(NurbsCurve2D),
436}
437
438impl Curve2D {
439    /// Evaluates the curve at parameter `t`.
440    #[must_use]
441    pub fn evaluate(&self, t: f64) -> Point2 {
442        match self {
443            Self::Line(c) => c.evaluate(t),
444            Self::Circle(c) => c.evaluate(t),
445            Self::Ellipse(c) => c.evaluate(t),
446            Self::Nurbs(c) => c.evaluate(t),
447        }
448    }
449
450    /// Returns the tangent at parameter `t`.
451    #[must_use]
452    pub fn tangent(&self, t: f64) -> Vec2 {
453        match self {
454            Self::Line(c) => c.tangent(t),
455            Self::Circle(c) => c.tangent(t),
456            Self::Ellipse(c) => c.tangent(t),
457            Self::Nurbs(c) => c.tangent(t),
458        }
459    }
460}
461
462#[cfg(test)]
463#[allow(clippy::unwrap_used, clippy::expect_used)]
464mod tests {
465    use super::*;
466    use std::f64::consts::PI;
467
468    const TOL: f64 = 1e-10;
469
470    fn approx_eq(a: f64, b: f64) -> bool {
471        (a - b).abs() < TOL
472    }
473
474    fn point2_approx_eq(a: Point2, b: Point2) -> bool {
475        approx_eq(a.x(), b.x()) && approx_eq(a.y(), b.y())
476    }
477
478    // ── Line2D tests ──────────────────────────────────
479
480    #[test]
481    fn line2d_evaluate() {
482        // Direction is normalized, so parameter t is arc-length
483        let line = Line2D::new(Point2::new(1.0, 2.0), Vec2::new(3.0, 4.0)).expect("valid line");
484
485        let p = line.evaluate(0.0);
486        assert!(point2_approx_eq(p, Point2::new(1.0, 2.0)));
487
488        // direction normalized to (0.6, 0.8), so at t=5 we get (1+3, 2+4) = (4, 6)
489        let p = line.evaluate(5.0);
490        assert!(point2_approx_eq(p, Point2::new(4.0, 6.0)));
491
492        let p = line.evaluate(2.5);
493        assert!(point2_approx_eq(p, Point2::new(2.5, 4.0)));
494    }
495
496    #[test]
497    fn line2d_project() {
498        let line = Line2D::new(Point2::new(0.0, 0.0), Vec2::new(1.0, 0.0)).expect("valid line");
499
500        assert!(approx_eq(line.project(Point2::new(5.0, 0.0)), 5.0));
501        assert!(approx_eq(line.project(Point2::new(3.0, 7.0)), 3.0));
502    }
503
504    #[test]
505    fn line2d_distance() {
506        let line = Line2D::new(Point2::new(0.0, 0.0), Vec2::new(1.0, 0.0)).expect("valid line");
507        assert!(approx_eq(
508            line.distance_to_point(Point2::new(3.0, 4.0)),
509            4.0
510        ));
511    }
512
513    #[test]
514    fn line2d_zero_direction_error() {
515        let result = Line2D::new(Point2::new(0.0, 0.0), Vec2::new(0.0, 0.0));
516        assert!(result.is_err());
517    }
518
519    // ── Circle2D tests ────────────────────────────────
520
521    #[test]
522    fn circle2d_evaluate_at_zero() {
523        let circle = Circle2D::new(Point2::new(0.0, 0.0), 1.0).expect("valid circle");
524        let p = circle.evaluate(0.0);
525        assert!(point2_approx_eq(p, Point2::new(1.0, 0.0)));
526    }
527
528    #[test]
529    fn circle2d_evaluate_quarter() {
530        let circle = Circle2D::new(Point2::new(0.0, 0.0), 2.0).expect("valid circle");
531        let p = circle.evaluate(PI / 2.0);
532        assert!(point2_approx_eq(p, Point2::new(0.0, 2.0)));
533    }
534
535    #[test]
536    fn circle2d_circumference() {
537        let circle = Circle2D::new(Point2::new(0.0, 0.0), 3.0).expect("valid circle");
538        assert!(approx_eq(circle.circumference(), 6.0 * PI));
539    }
540
541    #[test]
542    fn circle2d_project_roundtrip() {
543        let circle = Circle2D::new(Point2::new(1.0, 1.0), 5.0).expect("valid circle");
544        let t = 1.234;
545        let point = circle.evaluate(t);
546        let t_proj = circle.project(point);
547        assert!(approx_eq(t, t_proj));
548    }
549
550    #[test]
551    fn circle2d_zero_radius_error() {
552        let result = Circle2D::new(Point2::new(0.0, 0.0), 0.0);
553        assert!(result.is_err());
554    }
555
556    // ── Ellipse2D tests ───────────────────────────────
557
558    #[test]
559    fn ellipse2d_evaluate_no_rotation() {
560        let ellipse = Ellipse2D::new(Point2::new(0.0, 0.0), 3.0, 2.0, 0.0).expect("valid ellipse");
561
562        let p = ellipse.evaluate(0.0);
563        assert!(point2_approx_eq(p, Point2::new(3.0, 0.0)));
564
565        let p = ellipse.evaluate(PI / 2.0);
566        assert!(point2_approx_eq(p, Point2::new(0.0, 2.0)));
567    }
568
569    #[test]
570    fn ellipse2d_evaluate_with_rotation() {
571        let ellipse =
572            Ellipse2D::new(Point2::new(0.0, 0.0), 3.0, 2.0, PI / 2.0).expect("valid ellipse");
573        let p = ellipse.evaluate(0.0);
574        assert!(point2_approx_eq(p, Point2::new(0.0, 3.0)));
575    }
576
577    #[test]
578    fn ellipse2d_circle_circumference() {
579        let ellipse = Ellipse2D::new(Point2::new(0.0, 0.0), 5.0, 5.0, 0.0).expect("valid ellipse");
580        assert!(approx_eq(
581            ellipse.approximate_circumference(),
582            2.0 * PI * 5.0
583        ));
584    }
585
586    #[test]
587    fn ellipse2d_zero_axis_error() {
588        assert!(Ellipse2D::new(Point2::new(0.0, 0.0), 0.0, 1.0, 0.0).is_err());
589        assert!(Ellipse2D::new(Point2::new(0.0, 0.0), 1.0, 0.0, 0.0).is_err());
590    }
591
592    // ── NurbsCurve2D tests ────────────────────────────
593
594    #[test]
595    fn nurbs2d_line_segment() {
596        let curve =
597            NurbsCurve2D::from_line(Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)).expect("valid");
598
599        assert!(point2_approx_eq(curve.evaluate(0.0), Point2::new(0.0, 0.0)));
600        assert!(point2_approx_eq(curve.evaluate(1.0), Point2::new(1.0, 1.0)));
601        assert!(point2_approx_eq(curve.evaluate(0.5), Point2::new(0.5, 0.5)));
602    }
603
604    #[test]
605    fn nurbs2d_quadratic_bezier() {
606        let curve = NurbsCurve2D::new(
607            2,
608            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
609            vec![
610                Point2::new(0.0, 0.0),
611                Point2::new(0.5, 1.0),
612                Point2::new(1.0, 0.0),
613            ],
614            vec![1.0, 1.0, 1.0],
615        )
616        .expect("valid curve");
617
618        assert!(point2_approx_eq(curve.evaluate(0.0), Point2::new(0.0, 0.0)));
619        assert!(point2_approx_eq(curve.evaluate(1.0), Point2::new(1.0, 0.0)));
620        assert!(point2_approx_eq(curve.evaluate(0.5), Point2::new(0.5, 0.5)));
621    }
622
623    #[test]
624    fn nurbs2d_tangent() {
625        let curve =
626            NurbsCurve2D::from_line(Point2::new(0.0, 0.0), Point2::new(2.0, 3.0)).expect("valid");
627        let tangent = curve.tangent(0.5);
628        assert!(approx_eq(tangent.x(), 2.0));
629        assert!(approx_eq(tangent.y(), 3.0));
630    }
631
632    #[test]
633    fn nurbs2d_domain() {
634        let curve = NurbsCurve2D::new(
635            2,
636            vec![0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0],
637            vec![
638                Point2::new(0.0, 0.0),
639                Point2::new(0.25, 1.0),
640                Point2::new(0.75, 1.0),
641                Point2::new(1.0, 0.0),
642            ],
643            vec![1.0, 1.0, 1.0, 1.0],
644        )
645        .expect("valid curve");
646
647        let (u_min, u_max) = curve.domain();
648        assert!(approx_eq(u_min, 0.0));
649        assert!(approx_eq(u_max, 1.0));
650    }
651
652    #[test]
653    fn nurbs2d_invalid_knots() {
654        let result = NurbsCurve2D::new(
655            1,
656            vec![0.0, 1.0],
657            vec![Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)],
658            vec![1.0, 1.0],
659        );
660        assert!(result.is_err());
661    }
662
663    #[test]
664    fn nurbs2d_invalid_weights() {
665        let result = NurbsCurve2D::new(
666            1,
667            vec![0.0, 0.0, 1.0, 1.0],
668            vec![Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)],
669            vec![1.0],
670        );
671        assert!(result.is_err());
672    }
673
674    #[test]
675    fn nurbs2d_is_rational() {
676        let non_rational =
677            NurbsCurve2D::from_line(Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)).expect("valid");
678        assert!(!non_rational.is_rational());
679
680        let rational = NurbsCurve2D::new(
681            1,
682            vec![0.0, 0.0, 1.0, 1.0],
683            vec![Point2::new(0.0, 0.0), Point2::new(1.0, 1.0)],
684            vec![1.0, 2.0],
685        )
686        .expect("valid");
687        assert!(rational.is_rational());
688    }
689
690    // ── Curve2D enum tests ────────────────────────────
691
692    #[test]
693    fn curve2d_evaluate_dispatch() {
694        let line =
695            Curve2D::Line(Line2D::new(Point2::new(0.0, 0.0), Vec2::new(1.0, 0.0)).expect("valid"));
696        assert!(point2_approx_eq(line.evaluate(5.0), Point2::new(5.0, 0.0)));
697
698        let circle = Curve2D::Circle(Circle2D::new(Point2::new(0.0, 0.0), 1.0).expect("valid"));
699        assert!(point2_approx_eq(
700            circle.evaluate(0.0),
701            Point2::new(1.0, 0.0)
702        ));
703    }
704
705    // ── Ellipse2D additional tests ────────────────────
706
707    #[test]
708    fn ellipse2d_tangent_no_rotation() {
709        // At t=0, no rotation: tangent = (dx*cos_r - dy*sin_r, dx*sin_r + dy*cos_r)
710        // dx = -a*sin(0) = 0, dy = b*cos(0) = b, rotation=0 → tangent = (0, b)
711        let ellipse = Ellipse2D::new(Point2::new(0.0, 0.0), 3.0, 2.0, 0.0).expect("valid ellipse");
712        let tang = ellipse.tangent(0.0);
713        assert!(approx_eq(tang.x(), 0.0));
714        assert!(approx_eq(tang.y(), 2.0));
715    }
716
717    #[test]
718    fn ellipse2d_tangent_at_quarter() {
719        // At t=π/2, no rotation: dx = -a*sin(π/2) = -a, dy = b*cos(π/2) = 0
720        // tangent = (-a, 0)
721        let ellipse = Ellipse2D::new(Point2::new(0.0, 0.0), 3.0, 2.0, 0.0).expect("valid ellipse");
722        let tang = ellipse.tangent(PI / 2.0);
723        assert!(approx_eq(tang.x(), -3.0));
724        assert!(approx_eq(tang.y(), 0.0));
725    }
726
727    #[test]
728    fn ellipse2d_minor_exceeds_major_error() {
729        // semi_minor > semi_major must be rejected
730        assert!(Ellipse2D::new(Point2::new(0.0, 0.0), 2.0, 5.0, 0.0).is_err());
731    }
732
733    // ── Circle2D tangent tests ────────────────────────
734
735    #[test]
736    fn circle2d_tangent_at_zero() {
737        // Tangent at t=0: (-r*sin(0), r*cos(0)) = (0, r)
738        let circle = Circle2D::new(Point2::new(0.0, 0.0), 3.0).expect("valid circle");
739        let tang = circle.tangent(0.0);
740        assert!(approx_eq(tang.x(), 0.0));
741        assert!(approx_eq(tang.y(), 3.0));
742    }
743
744    #[test]
745    fn circle2d_tangent_at_quarter() {
746        // Tangent at t=π/2: (-r*sin(π/2), r*cos(π/2)) = (-r, 0)
747        let circle = Circle2D::new(Point2::new(0.0, 0.0), 2.0).expect("valid circle");
748        let tang = circle.tangent(PI / 2.0);
749        assert!(approx_eq(tang.x(), -2.0));
750        assert!(approx_eq(tang.y(), 0.0));
751    }
752
753    #[test]
754    fn circle2d_tangent_perpendicular_to_radius() {
755        // The tangent should always be perpendicular to the radius direction
756        let circle = Circle2D::new(Point2::new(1.0, 2.0), 4.0).expect("valid circle");
757        for i in 0..8 {
758            let t = f64::from(i) * PI / 4.0;
759            let pt = circle.evaluate(t);
760            let tang = circle.tangent(t);
761            let radius_vec_x = pt.x() - circle.center().x();
762            let radius_vec_y = pt.y() - circle.center().y();
763            let dot = radius_vec_x * tang.x() + radius_vec_y * tang.y();
764            assert!(
765                approx_eq(dot, 0.0),
766                "tangent not perpendicular to radius at t={t}: dot={dot}"
767            );
768        }
769    }
770
771    // ── NurbsCurve2D additional tests ─────────────────
772
773    #[test]
774    fn nurbs2d_tangent_quadratic_bezier() {
775        // Quadratic Bezier: P0=(0,0), P1=(0.5,1), P2=(1,0)
776        // Endpoint tangents: B'(0) = 2*(P1-P0) = (1, 2), B'(1) = 2*(P2-P1) = (1, -2)
777        let curve = NurbsCurve2D::new(
778            2,
779            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
780            vec![
781                Point2::new(0.0, 0.0),
782                Point2::new(0.5, 1.0),
783                Point2::new(1.0, 0.0),
784            ],
785            vec![1.0, 1.0, 1.0],
786        )
787        .expect("valid curve");
788
789        let tang_start = curve.tangent(0.0);
790        assert!(
791            (tang_start.x() - 1.0).abs() < 1e-8,
792            "start tangent x: {}",
793            tang_start.x()
794        );
795        assert!(
796            (tang_start.y() - 2.0).abs() < 1e-8,
797            "start tangent y: {}",
798            tang_start.y()
799        );
800
801        let tang_end = curve.tangent(1.0);
802        assert!(
803            (tang_end.x() - 1.0).abs() < 1e-8,
804            "end tangent x: {}",
805            tang_end.x()
806        );
807        assert!(
808            (tang_end.y() + 2.0).abs() < 1e-8,
809            "end tangent y: {}",
810            tang_end.y()
811        );
812    }
813
814    #[test]
815    fn nurbs2d_tangent_midpoint_is_horizontal() {
816        // Symmetric arch P0=(0,0), P1=(0.5,1), P2=(1,0) — tangent at t=0.5 has zero y-component
817        let curve = NurbsCurve2D::new(
818            2,
819            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
820            vec![
821                Point2::new(0.0, 0.0),
822                Point2::new(0.5, 1.0),
823                Point2::new(1.0, 0.0),
824            ],
825            vec![1.0, 1.0, 1.0],
826        )
827        .expect("valid curve");
828
829        let tang = curve.tangent(0.5);
830        assert!(
831            tang.y().abs() < 1e-10,
832            "midpoint tangent y should be zero, got {}",
833            tang.y()
834        );
835        assert!(tang.x() > 0.0, "midpoint tangent x should be positive");
836    }
837
838    #[test]
839    fn nurbs2d_empty_input_error() {
840        // degree=0, expected_knots = 0+0+1 = 1; provide 1 knot, 0 control points, 0 weights.
841        // The weights length check passes (both 0), then the EmptyInput guard fires.
842        let result = NurbsCurve2D::new(0, vec![0.0], vec![], vec![]);
843        assert!(
844            matches!(result, Err(MathError::EmptyInput)),
845            "expected EmptyInput, got {result:?}"
846        );
847    }
848
849    #[test]
850    fn curve2d_tangent_dispatch_all_variants() {
851        // Line: constant direction (1, 0)
852        let line =
853            Curve2D::Line(Line2D::new(Point2::new(0.0, 0.0), Vec2::new(1.0, 0.0)).expect("valid"));
854        let tang = line.tangent(7.0);
855        assert!(approx_eq(tang.x(), 1.0));
856        assert!(approx_eq(tang.y(), 0.0));
857
858        // Circle (r=1): tangent at t=0 is (0, 1)
859        let circle = Curve2D::Circle(Circle2D::new(Point2::new(0.0, 0.0), 1.0).expect("valid"));
860        let tang = circle.tangent(0.0);
861        assert!(approx_eq(tang.x(), 0.0));
862        assert!(approx_eq(tang.y(), 1.0));
863
864        // Ellipse (a=4, b=2, rotation=0): tangent at t=0 is (0, b) = (0, 2)
865        let ellipse =
866            Curve2D::Ellipse(Ellipse2D::new(Point2::new(0.0, 0.0), 4.0, 2.0, 0.0).expect("valid"));
867        let tang = ellipse.tangent(0.0);
868        assert!(approx_eq(tang.x(), 0.0));
869        assert!(approx_eq(tang.y(), 2.0));
870
871        // NURBS line segment (0,0)→(2,3): tangent = (2, 3)
872        let nurbs = Curve2D::Nurbs(
873            NurbsCurve2D::from_line(Point2::new(0.0, 0.0), Point2::new(2.0, 3.0)).expect("valid"),
874        );
875        let tang = nurbs.tangent(0.5);
876        assert!(approx_eq(tang.x(), 2.0));
877        assert!(approx_eq(tang.y(), 3.0));
878    }
879}