1use crate::MathError;
7use crate::vec::{Point2, Vec2};
8
9#[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 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 #[must_use]
37 pub fn evaluate(&self, t: f64) -> Point2 {
38 self.origin + self.direction * t
39 }
40
41 #[must_use]
43 pub const fn tangent(&self, _t: f64) -> Vec2 {
44 self.direction
45 }
46
47 #[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 #[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 #[must_use]
65 pub const fn origin(&self) -> Point2 {
66 self.origin
67 }
68
69 #[must_use]
71 pub const fn direction(&self) -> Vec2 {
72 self.direction
73 }
74}
75
76#[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 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 #[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 #[must_use]
111 pub fn tangent(&self, t: f64) -> Vec2 {
112 Vec2::new(-self.radius * t.sin(), self.radius * t.cos())
113 }
114
115 #[must_use]
117 pub fn circumference(&self) -> f64 {
118 2.0 * std::f64::consts::PI * self.radius
119 }
120
121 #[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 #[must_use]
130 pub const fn center(&self) -> Point2 {
131 self.center
132 }
133
134 #[must_use]
136 pub const fn radius(&self) -> f64 {
137 self.radius
138 }
139}
140
141#[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 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 #[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 #[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 #[must_use]
217 pub const fn center(&self) -> Point2 {
218 self.center
219 }
220
221 #[must_use]
223 pub const fn semi_major(&self) -> f64 {
224 self.semi_major
225 }
226
227 #[must_use]
229 pub const fn semi_minor(&self) -> f64 {
230 self.semi_minor
231 }
232
233 #[must_use]
235 pub const fn rotation(&self) -> f64 {
236 self.rotation
237 }
238
239 #[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#[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 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 #[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 #[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 #[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 #[must_use]
379 pub const fn degree(&self) -> usize {
380 self.degree
381 }
382
383 #[must_use]
385 pub fn knots(&self) -> &[f64] {
386 &self.knots
387 }
388
389 #[must_use]
391 pub fn control_points(&self) -> &[Point2] {
392 &self.control_points
393 }
394
395 #[must_use]
397 pub fn weights(&self) -> &[f64] {
398 &self.weights
399 }
400
401 #[must_use]
403 pub fn is_rational(&self) -> bool {
404 self.weights.iter().any(|w| (*w - 1.0).abs() > f64::EPSILON)
405 }
406
407 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#[derive(Debug, Clone)]
423#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
424pub enum Curve2D {
425 Line(Line2D),
427 Circle(Circle2D),
429 Ellipse(Ellipse2D),
431 Nurbs(NurbsCurve2D),
433}
434
435impl Curve2D {
436 #[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 #[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 #[test]
478 fn line2d_evaluate() {
479 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 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 #[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 #[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 #[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 #[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 #[test]
705 fn ellipse2d_tangent_no_rotation() {
706 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 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 assert!(Ellipse2D::new(Point2::new(0.0, 0.0), 2.0, 5.0, 0.0).is_err());
728 }
729
730 #[test]
733 fn circle2d_tangent_at_zero() {
734 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 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 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 #[test]
771 fn nurbs2d_tangent_quadratic_bezier() {
772 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 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 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 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 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 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 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}