1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_math::{
17 Axis2, Circle2, Direction2, Ellipse2, Hyperbola2, KnotVector, Point2, Transform2, Vector2,
18 Weighted, bspline, elementary,
19};
20
21use crate::traits::{Curve2d, CurveKind, Reversible};
22
23const TAU: f64 = core::f64::consts::TAU;
24
25#[derive(Debug, Clone, PartialEq)]
27pub enum PlanarCurve {
28 Line(Line2d),
30 Circle(Circle2d),
32 Ellipse(Ellipse2d),
34 Hyperbola(Hyperbola2d),
36 BSpline(BSpline2d),
38 Trimmed(Box<Trimmed2d>),
40 Offset(Box<Offset2d>),
42 Trig(Trig2d),
46}
47
48#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Line2d {
51 axis: Axis2,
52 domain: (f64, f64),
53}
54
55#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct Circle2d {
58 circle: Circle2,
59 reversed: bool,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct Ellipse2d {
65 ellipse: Ellipse2,
66 reversed: bool,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct Hyperbola2d {
73 hyperbola: Hyperbola2,
74 domain: (f64, f64),
75 reversed: bool,
76}
77
78#[derive(Debug, Clone, PartialEq)]
80pub struct BSpline2d {
81 knots: KnotVector,
82 control: Vec<Weighted<Point2>>,
83 rational: bool,
84}
85
86#[derive(Debug, Clone, PartialEq)]
88pub struct Trimmed2d {
89 basis: PlanarCurve,
90 domain: (f64, f64),
91 reversed: bool,
92}
93
94fn mirror(u: f64, a: f64, b: f64) -> f64 {
96 a + b - u
97}
98
99#[derive(Debug, Clone, Copy, PartialEq)]
107pub struct Trig2d {
108 c: Point2,
109 d: Vector2,
110 a: Vector2,
111 b: Vector2,
112 domain: (f64, f64),
113 reversed: bool,
114}
115
116impl Trig2d {
117 pub fn new(
124 c: Point2,
125 d: Vector2,
126 a: Vector2,
127 b: Vector2,
128 domain: (f64, f64),
129 ) -> OgeomResult<Self> {
130 let finite = c.to_vector().is_finite()
131 && d.is_finite()
132 && a.is_finite()
133 && b.is_finite()
134 && domain.0.is_finite()
135 && domain.1.is_finite();
136 if !finite || domain.0 >= domain.1 {
137 ogeom_bail!(
138 Construction,
139 "a trig curve needs finite coefficients and an increasing domain"
140 );
141 }
142 Ok(Self {
143 c,
144 d,
145 a,
146 b,
147 domain,
148 reversed: false,
149 })
150 }
151
152 #[must_use]
154 pub const fn constant(&self) -> Point2 {
155 self.c
156 }
157
158 #[must_use]
160 pub const fn linear(&self) -> Vector2 {
161 self.d
162 }
163
164 #[must_use]
166 pub const fn cosine(&self) -> Vector2 {
167 self.a
168 }
169
170 #[must_use]
172 pub const fn sine(&self) -> Vector2 {
173 self.b
174 }
175
176 #[must_use]
178 pub const fn is_reversed(&self) -> bool {
179 self.reversed
180 }
181
182 fn raw(&self, t: f64) -> Point2 {
183 let (sin, cos) = t.sin_cos();
184 self.c + self.d * t + self.a * cos + self.b * sin
185 }
186}
187
188impl Curve2d for Trig2d {
189 fn domain(&self) -> (f64, f64) {
190 self.domain
191 }
192
193 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
194 let u = clamp_to_domain(u, self.domain, false, tol)?;
195 let t = if self.reversed {
196 self.domain.0 + self.domain.1 - u
197 } else {
198 u
199 };
200 Ok(self.raw(t))
201 }
202
203 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
204 Ok(self.derivatives_at(u, 1, tol)?[1])
205 }
206
207 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
208 let u = clamp_to_domain(u, self.domain, false, tol)?;
209 let t = if self.reversed {
210 self.domain.0 + self.domain.1 - u
211 } else {
212 u
213 };
214 let (sin, cos) = t.sin_cos();
215 let sign = if self.reversed { -1.0 } else { 1.0 };
216 let mut out = Vec::with_capacity(n + 1);
217 out.push(self.raw(t).to_vector());
218 for order in 1..=n {
219 let trig = match order % 4 {
223 1 => self.a * -sin + self.b * cos,
224 2 => self.a * -cos + self.b * -sin,
225 3 => self.a * sin + self.b * -cos,
226 _ => self.a * cos + self.b * sin,
227 };
228 let linear = if order == 1 { self.d } else { Vector2::ZERO };
229 let odd = if order % 2 == 1 { sign } else { 1.0 };
230 out.push((trig + linear) * odd);
231 }
232 Ok(out)
233 }
234
235 fn kind(&self) -> CurveKind {
236 CurveKind::Trig
237 }
238
239 fn is_closed(&self, tol: Tolerances) -> bool {
240 self.raw(self.domain.0).distance(self.raw(self.domain.1)) <= tol.confusion()
241 }
242
243 fn is_periodic(&self) -> bool {
244 false
245 }
246}
247
248#[derive(Debug, Clone, PartialEq)]
258pub struct Offset2d {
259 basis: PlanarCurve,
260 distance: f64,
261}
262
263impl Offset2d {
264 pub fn new(basis: PlanarCurve, distance: f64) -> OgeomResult<Self> {
271 if !distance.is_finite() || distance == 0.0 {
272 ogeom_bail!(
273 Construction,
274 "an offset of {distance} is not a displacement"
275 );
276 }
277 Ok(Self { basis, distance })
278 }
279
280 #[must_use]
282 pub const fn basis(&self) -> &PlanarCurve {
283 &self.basis
284 }
285
286 #[must_use]
288 pub const fn distance(&self) -> f64 {
289 self.distance
290 }
291
292 fn normal_and_slope(&self, t: f64, tol: Tolerances) -> OgeomResult<(Vector2, Vector2)> {
295 let d = self.basis.derivatives_at(t, 2, tol)?;
296 let rot = |v: Vector2| Vector2::new(-v.y, v.x);
297 let w = rot(d[1]);
298 let m = w.magnitude();
299 if m <= tol.confusion() {
300 ogeom_bail!(
301 Construction,
302 "the basis has no tangent at {t}; the offset direction is undefined"
303 );
304 }
305 let n = w / m;
306 let wp = rot(d[2]);
307 let np = (wp - n * n.dot(wp)) / m;
308 Ok((n, np))
309 }
310}
311
312impl Curve2d for Offset2d {
313 fn domain(&self) -> (f64, f64) {
314 self.basis.domain()
315 }
316
317 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
318 let base = self.basis.point_at(u, tol)?;
319 let (n, _) = self.normal_and_slope(u, tol)?;
320 Ok(base + n * self.distance)
321 }
322
323 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
324 let d1 = self.basis.d1_at(u, tol)?;
325 let (_, np) = self.normal_and_slope(u, tol)?;
326 Ok(d1 + np * self.distance)
327 }
328
329 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
330 if n >= 2 {
331 ogeom_bail!(
332 Construction,
333 "an offset curve's second derivative needs its basis's third, which the \
334 vocabulary does not carry"
335 );
336 }
337 let mut out = vec![self.point_at(u, tol)?.to_vector()];
338 if n >= 1 {
339 out.push(self.d1_at(u, tol)?);
340 }
341 Ok(out)
342 }
343
344 fn kind(&self) -> CurveKind {
345 CurveKind::Offset
346 }
347
348 fn is_closed(&self, tol: Tolerances) -> bool {
349 self.basis.is_closed(tol)
350 }
351
352 fn is_periodic(&self) -> bool {
353 self.basis.is_periodic()
354 }
355}
356
357impl Line2d {
358 pub fn segment(from: Point2, to: Point2, tol: Tolerances) -> OgeomResult<Self> {
365 Ok(Self {
366 axis: Axis2::through(from, to, tol)?,
367 domain: (0.0, from.distance(to)),
368 })
369 }
370
371 pub fn over(axis: Axis2, start: f64, end: f64) -> OgeomResult<Self> {
378 if !start.is_finite() || !end.is_finite() || end <= start {
379 ogeom_bail!(Construction, "line range [{start}, {end}] is empty");
380 }
381 Ok(Self {
382 axis,
383 domain: (start, end),
384 })
385 }
386
387 #[must_use]
389 pub const fn axis(&self) -> Axis2 {
390 self.axis
391 }
392}
393
394impl Circle2d {
395 #[must_use]
397 pub const fn new(circle: Circle2) -> Self {
398 Self {
399 circle,
400 reversed: false,
401 }
402 }
403
404 #[must_use]
406 pub const fn circle(&self) -> Circle2 {
407 self.circle
408 }
409
410 #[must_use]
416 pub const fn is_reversed(&self) -> bool {
417 self.reversed
418 }
419}
420
421impl Hyperbola2d {
422 pub fn over(hyperbola: Hyperbola2, start: f64, end: f64) -> OgeomResult<Self> {
429 if !start.is_finite() || !end.is_finite() || start >= end {
430 ogeom_bail!(
431 Construction,
432 "hyperbola domain [{start}, {end}] must be finite and increasing"
433 );
434 }
435 Ok(Self {
436 hyperbola,
437 domain: (start, end),
438 reversed: false,
439 })
440 }
441
442 #[must_use]
444 pub const fn hyperbola(&self) -> Hyperbola2 {
445 self.hyperbola
446 }
447
448 #[must_use]
450 pub const fn is_reversed(&self) -> bool {
451 self.reversed
452 }
453}
454
455impl Ellipse2d {
456 #[must_use]
458 pub const fn new(ellipse: Ellipse2) -> Self {
459 Self {
460 ellipse,
461 reversed: false,
462 }
463 }
464
465 #[must_use]
467 pub const fn ellipse(&self) -> Ellipse2 {
468 self.ellipse
469 }
470
471 #[must_use]
477 pub const fn is_reversed(&self) -> bool {
478 self.reversed
479 }
480}
481
482impl BSpline2d {
483 pub fn new(knots: KnotVector, control: Vec<Point2>, tol: Tolerances) -> OgeomResult<Self> {
489 let weighted = control
490 .into_iter()
491 .map(|p| Weighted::new(p, 1.0, tol))
492 .collect::<OgeomResult<Vec<_>>>()?;
493 Self::rational(knots, weighted)
494 }
495
496 pub fn rational(knots: KnotVector, control: Vec<Weighted<Point2>>) -> OgeomResult<Self> {
502 if control.len() != knots.control_point_count() {
503 ogeom_bail!(
504 Dimension,
505 "knot vector describes {} control points, got {}",
506 knots.control_point_count(),
507 control.len()
508 );
509 }
510 let first = control[0].weight;
511 let rational = control
512 .iter()
513 .any(|w| (w.weight - first).abs() > 1e-12 * first.abs());
514 Ok(Self {
515 knots,
516 control,
517 rational,
518 })
519 }
520
521 #[must_use]
523 pub const fn knots(&self) -> &KnotVector {
524 &self.knots
525 }
526
527 #[must_use]
529 pub fn control_points(&self) -> &[Weighted<Point2>] {
530 &self.control
531 }
532
533 #[must_use]
535 pub const fn is_rational(&self) -> bool {
536 self.rational
537 }
538
539 pub fn reseamed_at(&self, u: f64, lap: Vector2, tol: Tolerances) -> OgeomResult<Self> {
555 let (start, end) = self.domain();
556 let (head, tail) = (self.point_at(start, tol)? + lap, self.point_at(end, tol)?);
557 if head.distance(tail) > tol.parametric() {
558 ogeom_bail!(
559 Construction,
560 "the image does not close over its lap: its ends are {:.3e} apart",
561 head.distance(tail)
562 );
563 }
564 let ((before_knots, before), (after_knots, after)) =
565 bspline::split(&self.knots, &self.control, u, tol)?;
566 let carried = before
567 .iter()
568 .map(|w| Weighted::new(w.point() + lap, w.weight, tol))
569 .collect::<OgeomResult<Vec<_>>>()?;
570 let (knots, control) = bspline::join(&(after_knots, after), &(before_knots, carried))?;
571 Self::rational(knots, control)
572 }
573}
574
575impl Trimmed2d {
576 pub fn new(basis: PlanarCurve, start: f64, end: f64, tol: Tolerances) -> OgeomResult<Self> {
583 let (a, b) = basis.domain();
584 if !start.is_finite() || !end.is_finite() || end <= start + tol.parametric() {
585 ogeom_bail!(Domain, "trim range [{start}, {end}] is empty");
586 }
587 if !basis.is_periodic() && (start < a - tol.parametric() || end > b + tol.parametric()) {
588 ogeom_bail!(Domain, "trim range [{start}, {end}] leaves [{a}, {b}]");
589 }
590 Ok(Self {
591 basis,
592 domain: (start, end),
593 reversed: false,
594 })
595 }
596
597 #[must_use]
599 pub const fn basis(&self) -> &PlanarCurve {
600 &self.basis
601 }
602
603 #[must_use]
609 pub const fn is_reversed(&self) -> bool {
610 self.reversed
611 }
612
613 fn basis_parameter(&self, u: f64, tol: Tolerances) -> OgeomResult<f64> {
615 let u = clamp_to_domain(u, self.domain, false, tol)?;
616 Ok(if self.reversed {
617 mirror(u, self.domain.0, self.domain.1)
618 } else {
619 u
620 })
621 }
622}
623
624fn clamp_to_domain(
626 u: f64,
627 domain: (f64, f64),
628 periodic: bool,
629 tol: Tolerances,
630) -> OgeomResult<f64> {
631 let (a, b) = domain;
632 if periodic {
633 return Ok(a + (u - a).rem_euclid(b - a));
634 }
635 let slack = crate::traits::domain_slack(a, b, tol);
636 if !u.is_finite() || u < a - slack || u > b + slack {
637 ogeom_bail!(Domain, "parameter {u} outside curve domain [{a}, {b}]");
638 }
639 Ok(u.clamp(a, b))
640}
641
642fn turning(point: Vector2, d1: Vector2, d2: Vector2, n: usize, sign: f64) -> Vec<Vector2> {
646 let mut out = vec![point];
647 let mut factor = 1.0;
648 for order in 1..=n {
649 factor *= sign;
650 let d = match order % 4 {
651 1 => d1,
652 2 => d2,
653 3 => -d1,
654 _ => -d2,
655 };
656 out.push(d * factor);
657 }
658 out
659}
660
661fn pad(mut out: Vec<Vector2>, n: usize) -> Vec<Vector2> {
663 out.resize(n.max(out.len().saturating_sub(1)) + 1, Vector2::ZERO);
664 out.truncate(n + 1);
665 out
666}
667
668impl Curve2d for Line2d {
669 fn domain(&self) -> (f64, f64) {
670 self.domain
671 }
672
673 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
674 Ok(self
675 .axis
676 .point_at(clamp_to_domain(u, self.domain, false, tol)?))
677 }
678
679 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
680 clamp_to_domain(u, self.domain, false, tol)?;
681 Ok(self.axis.direction.vector())
682 }
683
684 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
685 let p = self.point_at(u, tol)?;
686 Ok(pad(vec![p.to_vector(), self.axis.direction.vector()], n))
687 }
688
689 fn kind(&self) -> CurveKind {
690 CurveKind::Line
691 }
692
693 fn is_closed(&self, _tol: Tolerances) -> bool {
694 false
695 }
696
697 fn is_periodic(&self) -> bool {
698 false
699 }
700}
701
702impl Curve2d for Circle2d {
703 fn domain(&self) -> (f64, f64) {
704 (0.0, TAU)
705 }
706
707 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
708 let u = clamp_to_domain(u, self.domain(), true, tol)?;
710 let angle = if self.reversed { -u } else { u };
711 let f = self.circle.frame();
712 let r = self.circle.radius();
713 let (sin, cos) = angle.sin_cos();
714 let (x, y) = (f.x().vector(), f.y().vector());
715 Ok(self.circle.centre() + x * (r * cos) + y * (r * sin))
716 }
717
718 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
719 Ok(self.derivatives_at(u, 1, tol)?[1])
720 }
721
722 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
723 let u = clamp_to_domain(u, self.domain(), true, tol)?;
724 let angle = if self.reversed { -u } else { u };
725 let f = self.circle.frame();
726 let r = self.circle.radius();
727 let (sin, cos) = angle.sin_cos();
728 let (x, y) = (f.x().vector(), f.y().vector());
729 let point = self.circle.centre() + x * (r * cos) + y * (r * sin);
730 let sign = if self.reversed { -1.0 } else { 1.0 };
731 Ok(turning(
732 point.to_vector(),
733 x * (-r * sin) + y * (r * cos),
734 x * (-r * cos) + y * (-r * sin),
735 n,
736 sign,
737 ))
738 }
739
740 fn kind(&self) -> CurveKind {
741 CurveKind::Circle
742 }
743
744 fn is_closed(&self, _tol: Tolerances) -> bool {
745 true
746 }
747
748 fn is_periodic(&self) -> bool {
749 true
750 }
751}
752
753impl Curve2d for Ellipse2d {
754 fn domain(&self) -> (f64, f64) {
755 (0.0, TAU)
756 }
757
758 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
759 Ok(Point2::from_vector(self.derivatives_at(u, 0, tol)?[0]))
760 }
761
762 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
763 Ok(self.derivatives_at(u, 1, tol)?[1])
764 }
765
766 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
767 let u = clamp_to_domain(u, self.domain(), true, tol)?;
768 let angle = if self.reversed { -u } else { u };
769 let f = self.ellipse.frame();
770 let (a, b) = (self.ellipse.major_radius(), self.ellipse.minor_radius());
771 let (sin, cos) = angle.sin_cos();
772 let (x, y) = (f.x().vector(), f.y().vector());
773 let point = self.ellipse.centre() + x * (a * cos) + y * (b * sin);
774 let sign = if self.reversed { -1.0 } else { 1.0 };
775 Ok(turning(
776 point.to_vector(),
777 x * (-a * sin) + y * (b * cos),
778 x * (-a * cos) + y * (-b * sin),
779 n,
780 sign,
781 ))
782 }
783
784 fn kind(&self) -> CurveKind {
785 CurveKind::Ellipse
786 }
787
788 fn is_closed(&self, _tol: Tolerances) -> bool {
789 true
790 }
791
792 fn is_periodic(&self) -> bool {
793 true
794 }
795}
796
797impl Curve2d for Hyperbola2d {
798 fn domain(&self) -> (f64, f64) {
799 self.domain
800 }
801
802 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
803 Ok(Point2::from_vector(self.derivatives_at(u, 0, tol)?[0]))
804 }
805
806 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
807 Ok(self.derivatives_at(u, 1, tol)?[1])
808 }
809
810 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
811 let u = clamp_to_domain(u, self.domain, false, tol)?;
812 let t = if self.reversed {
813 mirror(u, self.domain.0, self.domain.1)
814 } else {
815 u
816 };
817 let f = self.hyperbola.frame();
818 let (a, b) = (self.hyperbola.major_radius(), self.hyperbola.minor_radius());
819 let (cosh, sinh) = (t.cosh(), t.sinh());
820 let (x, y) = (f.x().vector(), f.y().vector());
821 let sign = if self.reversed { -1.0 } else { 1.0 };
822 let odd = x * (a * sinh) + y * (b * cosh);
826 let even = x * (a * cosh) + y * (b * sinh);
827 let mut out = Vec::with_capacity(n + 1);
828 out.push(self.hyperbola.centre().to_vector() + even);
829 for order in 1..=n {
830 out.push(if order % 2 == 1 { odd * sign } else { even });
831 }
832 Ok(out)
833 }
834
835 fn kind(&self) -> CurveKind {
836 CurveKind::Hyperbola
837 }
838
839 fn is_closed(&self, _tol: Tolerances) -> bool {
840 false
841 }
842
843 fn is_periodic(&self) -> bool {
844 false
845 }
846}
847
848impl Curve2d for BSpline2d {
849 fn domain(&self) -> (f64, f64) {
850 self.knots.domain()
851 }
852
853 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
854 let u = clamp_to_domain(u, self.domain(), false, tol)?;
855 if self.rational {
856 bspline::evaluate_rational(&self.knots, &self.control, u, tol)
857 } else {
858 Ok(bspline::evaluate(&self.knots, &self.control, u, tol)?.point())
859 }
860 }
861
862 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
863 Ok(self.derivatives_at(u, 1, tol)?[1])
864 }
865
866 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
867 let u = clamp_to_domain(u, self.domain(), false, tol)?;
868 let points = bspline::rational_derivatives(&self.knots, &self.control, u, n, tol)?;
869 Ok(points.into_iter().map(Point2::to_vector).collect())
870 }
871
872 fn kind(&self) -> CurveKind {
873 CurveKind::BSpline
874 }
875
876 fn is_closed(&self, tol: Tolerances) -> bool {
879 match (self.start(tol), self.end(tol)) {
880 (Ok(a), Ok(b)) => a.is_equal(b, tol),
881 _ => false,
882 }
883 }
884
885 fn is_periodic(&self) -> bool {
886 false
887 }
888}
889
890impl Curve2d for Trimmed2d {
891 fn domain(&self) -> (f64, f64) {
892 self.domain
893 }
894
895 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
896 self.basis.point_at(self.basis_parameter(u, tol)?, tol)
897 }
898
899 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
900 let d = self.basis.d1_at(self.basis_parameter(u, tol)?, tol)?;
901 Ok(if self.reversed { -d } else { d })
902 }
903
904 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
905 let t = self.basis_parameter(u, tol)?;
906 let mut out = self.basis.derivatives_at(t, n, tol)?;
907 if self.reversed {
908 for (order, d) in out.iter_mut().enumerate() {
909 if order % 2 == 1 {
910 *d = -*d;
911 }
912 }
913 }
914 Ok(out)
915 }
916
917 fn kind(&self) -> CurveKind {
918 CurveKind::Trimmed
919 }
920
921 fn is_closed(&self, tol: Tolerances) -> bool {
922 match (self.start(tol), self.end(tol)) {
923 (Ok(a), Ok(b)) => a.is_equal(b, tol),
924 _ => false,
925 }
926 }
927
928 fn is_periodic(&self) -> bool {
929 false
930 }
931}
932
933macro_rules! dispatch {
934 ($self:ident, $c:ident => $body:expr) => {
935 match $self {
936 Self::Line($c) => $body,
937 Self::Circle($c) => $body,
938 Self::Ellipse($c) => $body,
939 Self::Hyperbola($c) => $body,
940 Self::BSpline($c) => $body,
941 Self::Trimmed($c) => $body,
942 Self::Offset($c) => $body,
943 Self::Trig($c) => $body,
944 }
945 };
946}
947
948impl Curve2d for PlanarCurve {
949 fn domain(&self) -> (f64, f64) {
950 dispatch!(self, c => c.domain())
951 }
952
953 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
954 dispatch!(self, c => c.point_at(u, tol))
955 }
956
957 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
958 dispatch!(self, c => c.d1_at(u, tol))
959 }
960
961 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
962 dispatch!(self, c => c.derivatives_at(u, n, tol))
963 }
964
965 fn kind(&self) -> CurveKind {
966 dispatch!(self, c => c.kind())
967 }
968
969 fn is_closed(&self, tol: Tolerances) -> bool {
970 dispatch!(self, c => c.is_closed(tol))
971 }
972
973 fn is_periodic(&self) -> bool {
974 dispatch!(self, c => c.is_periodic())
975 }
976}
977
978impl PlanarCurve {
979 pub fn transformed(&self, t: &Transform2, tol: Tolerances) -> OgeomResult<Self> {
986 let scale = t.scale_factor().abs();
987 Ok(match self {
988 Self::Line(c) => Self::Line(Line2d {
989 axis: Axis2::new(
990 t.apply(c.axis.location),
991 Direction2::new(t.apply_vector(c.axis.direction.vector()), tol)?,
992 ),
993 domain: (c.domain.0 * scale, c.domain.1 * scale),
995 }),
996 Self::Circle(c) => Self::Circle(Circle2d {
997 circle: c.circle.transformed(t, tol)?,
998 ..*c
999 }),
1000 Self::Ellipse(c) => Self::Ellipse(Ellipse2d {
1001 ellipse: c.ellipse.transformed(t, tol)?,
1002 ..*c
1003 }),
1004 Self::Hyperbola(c) => Self::Hyperbola(Hyperbola2d {
1005 hyperbola: c.hyperbola.transformed(t, tol)?,
1006 ..*c
1007 }),
1008 Self::BSpline(c) => Self::BSpline(BSpline2d {
1009 control: c
1010 .control
1011 .iter()
1012 .map(|w| Weighted::new(t.apply(w.point()), w.weight, tol))
1013 .collect::<OgeomResult<Vec<_>>>()?,
1014 ..c.clone()
1015 }),
1016 Self::Offset(c) => Self::Offset(Box::new(Offset2d {
1019 basis: c.basis.transformed(t, tol)?,
1020 distance: c.distance * scale * if t.preserves_handedness() { 1.0 } else { -1.0 },
1021 })),
1022 Self::Trig(c) => Self::Trig(Trig2d {
1023 c: t.apply(c.c),
1024 d: t.apply_vector(c.d),
1025 a: t.apply_vector(c.a),
1026 b: t.apply_vector(c.b),
1027 ..*c
1028 }),
1029 Self::Trimmed(c) => {
1033 let basis = c.basis.transformed(t, tol)?;
1034 let (from, to) = (c.basis.domain(), basis.domain());
1035 Self::Trimmed(Box::new(Trimmed2d {
1036 domain: (
1037 crate::surface::carried(c.domain.0, from, to),
1038 crate::surface::carried(c.domain.1, from, to),
1039 ),
1040 basis,
1041 reversed: c.reversed,
1042 }))
1043 }
1044 })
1045 }
1046}
1047
1048impl PlanarCurve {
1049 pub fn stretched(&self, su: f64, sv: f64, tol: Tolerances) -> OgeomResult<Self> {
1062 if su == sv {
1063 if su == 1.0 {
1064 return Ok(self.clone());
1065 }
1066 let scaling = ogeom_math::Transform2::scaling(Point2::ORIGIN, su, tol)?;
1067 return self.transformed(&scaling, tol);
1068 }
1069 let map = |p: Point2| Point2::new(p.x * su, p.y * sv);
1070 Ok(match self {
1071 Self::Line(c) => {
1072 let (a, b) = c.domain;
1073 let knots = KnotVector::new(vec![a, a, b, b], 1)?;
1074 Self::BSpline(BSpline2d::new(
1075 knots,
1076 vec![map(c.axis.point_at(a)), map(c.axis.point_at(b))],
1077 tol,
1078 )?)
1079 }
1080 Self::BSpline(c) => Self::BSpline(BSpline2d {
1081 control: c
1082 .control
1083 .iter()
1084 .map(|w| Weighted::new(map(w.point()), w.weight, tol))
1085 .collect::<OgeomResult<Vec<_>>>()?,
1086 ..c.clone()
1087 }),
1088 Self::Trimmed(c) => Self::Trimmed(Box::new(Trimmed2d {
1089 basis: c.basis.stretched(su, sv, tol)?,
1090 ..(**c).clone()
1091 })),
1092 _ => ogeom_bail!(
1093 Construction,
1094 "a chart curve of this kind has no image under an uneven stretch"
1095 ),
1096 })
1097 }
1098}
1099
1100impl Reversible for PlanarCurve {
1101 fn reversed(&self) -> Self {
1102 match self {
1103 Self::Line(c) => Self::Line(Line2d {
1104 axis: Axis2::new(
1105 c.axis.point_at(c.domain.0 + c.domain.1),
1106 c.axis.direction.reversed(),
1107 ),
1108 domain: c.domain,
1109 }),
1110 Self::Circle(c) => Self::Circle(Circle2d {
1111 reversed: !c.reversed,
1112 ..*c
1113 }),
1114 Self::Ellipse(c) => Self::Ellipse(Ellipse2d {
1115 reversed: !c.reversed,
1116 ..*c
1117 }),
1118 Self::Hyperbola(c) => Self::Hyperbola(Hyperbola2d {
1119 reversed: !c.reversed,
1120 ..*c
1121 }),
1122 Self::BSpline(c) => {
1123 let (knots, control) = bspline::reverse(&c.knots, &c.control);
1124 Self::BSpline(BSpline2d {
1125 knots,
1126 control,
1127 ..c.clone()
1128 })
1129 }
1130 Self::Trimmed(c) => Self::Trimmed(Box::new(Trimmed2d {
1131 reversed: !c.reversed,
1132 ..(**c).clone()
1133 })),
1134 Self::Offset(c) => Self::Offset(Box::new(Offset2d {
1138 basis: c.basis.reversed(),
1139 distance: -c.distance,
1140 })),
1141 Self::Trig(c) => Self::Trig(Trig2d {
1142 reversed: !c.reversed,
1143 ..*c
1144 }),
1145 }
1146 }
1147}
1148
1149pub fn tangent_angle(curve: &PlanarCurve, t: f64, tol: Tolerances) -> OgeomResult<f64> {
1159 let d = curve.d1_at(t, tol)?;
1160 if d.is_zero(tol) {
1161 ogeom_bail!(Construction, "curve has no tangent at {t}");
1162 }
1163 Ok(elementary::wrap_signed_angle(d.y.atan2(d.x)))
1164}
1165
1166impl From<Line2d> for PlanarCurve {
1167 fn from(c: Line2d) -> Self {
1168 Self::Line(c)
1169 }
1170}
1171impl From<Circle2d> for PlanarCurve {
1172 fn from(c: Circle2d) -> Self {
1173 Self::Circle(c)
1174 }
1175}
1176impl From<Ellipse2d> for PlanarCurve {
1177 fn from(c: Ellipse2d) -> Self {
1178 Self::Ellipse(c)
1179 }
1180}
1181impl From<Hyperbola2d> for PlanarCurve {
1182 fn from(c: Hyperbola2d) -> Self {
1183 Self::Hyperbola(c)
1184 }
1185}
1186impl From<BSpline2d> for PlanarCurve {
1187 fn from(c: BSpline2d) -> Self {
1188 Self::BSpline(c)
1189 }
1190}
1191impl From<Trimmed2d> for PlanarCurve {
1192 fn from(c: Trimmed2d) -> Self {
1193 Self::Trimmed(Box::new(c))
1194 }
1195}
1196
1197#[cfg(test)]
1198#[allow(clippy::unwrap_used)]
1199mod tests {
1200 use super::*;
1201 use approx::assert_relative_eq;
1202 use ogeom_math::Frame2;
1203
1204 #[test]
1208 fn a_moved_offset_2d_is_the_image_of_the_original() {
1209 use ogeom_math::{Circle2, Transform2};
1210 let frame = Frame2::new(Point2::new(1.0, -2.0), Direction2::X);
1211 let circle = PlanarCurve::Circle(Circle2d::new(Circle2::new(frame, 2.0, T).unwrap()));
1212 let offset = PlanarCurve::Offset(Box::new(Offset2d::new(circle, 0.5).unwrap()));
1213 let mirror = Transform2::line_mirror(Point2::ORIGIN, Direction2::X);
1214 for t in [
1215 mirror,
1216 Transform2::scaling(Point2::new(3.0, 1.0), -2.0, T).unwrap(),
1217 Transform2::rotation(Point2::ORIGIN, 0.7),
1218 ] {
1219 let moved = offset.transformed(&t, T).unwrap();
1220 for u in [0.0, 1.0, 2.5, 4.0] {
1221 let want = t.apply(offset.point_at(u, T).unwrap());
1222 let got = moved.point_at(u, T).unwrap();
1223 assert!(
1224 got.distance(want) < 1e-9,
1225 "{t:?} at {u}: {got:?} against {want:?}"
1226 );
1227 }
1228 }
1229 }
1230
1231 #[test]
1232 fn an_offset_2d_circle_is_the_larger_circle() {
1233 use ogeom_math::Circle2;
1234 let frame = Frame2::new(Point2::new(1.0, -2.0), Direction2::X);
1237 let circle = PlanarCurve::Circle(Circle2d::new(Circle2::new(frame, 2.0, T).unwrap()));
1238 let offset = Offset2d::new(circle, -1.0).unwrap();
1239 for i in 0..8 {
1240 let t = core::f64::consts::TAU * f64::from(i) / 8.0;
1241 let p = offset.point_at(t, T).unwrap();
1242 assert!((p.distance(Point2::new(1.0, -2.0)) - 3.0).abs() < 1e-12);
1243 }
1244 let h = 1e-6;
1245 let d = offset.d1_at(1.0, T).unwrap();
1246 let fd = (offset.point_at(1.0 + h, T).unwrap() - offset.point_at(1.0 - h, T).unwrap())
1247 / (2.0 * h);
1248 assert!((d - fd).magnitude() < 1e-5);
1249 assert!(offset.derivatives_at(1.0, 2, T).is_err());
1250 }
1251
1252 const T: Tolerances = Tolerances::millimetres();
1253
1254 fn frame() -> Frame2 {
1255 Frame2::new(Point2::new(2.0, -1.0), Direction2::from_angle(0.4))
1256 }
1257
1258 fn every_curve() -> Vec<PlanarCurve> {
1259 let spline = {
1260 let control = vec![
1261 Point2::new(0.0, 0.0),
1262 Point2::new(1.0, 2.0),
1263 Point2::new(3.0, 1.0),
1264 Point2::new(5.0, 0.0),
1265 Point2::new(6.0, -1.0),
1266 ];
1267 BSpline2d::new(
1268 KnotVector::clamped_uniform(3, control.len()).unwrap(),
1269 control,
1270 T,
1271 )
1272 .unwrap()
1273 };
1274 vec![
1275 Line2d::segment(Point2::ORIGIN, Point2::new(3.0, 4.0), T)
1276 .unwrap()
1277 .into(),
1278 Circle2d::new(Circle2::new(frame(), 2.0, T).unwrap()).into(),
1279 Ellipse2d::new(Ellipse2::new(frame(), 5.0, 3.0, T).unwrap()).into(),
1280 Hyperbola2d::over(Hyperbola2::new(frame(), 2.0, 0.7, T).unwrap(), -1.1, 0.6)
1281 .unwrap()
1282 .into(),
1283 spline.clone().into(),
1284 Trimmed2d::new(spline.into(), 0.2, 0.8, T).unwrap().into(),
1285 ]
1286 }
1287
1288 fn interior(c: &PlanarCurve, n: usize) -> Vec<f64> {
1289 let (a, b) = c.domain();
1290 (1..n)
1291 .map(|i| {
1292 #[allow(clippy::cast_precision_loss)]
1293 let t = i as f64 / n as f64 + 0.0413;
1294 a + (b - a) * t
1295 })
1296 .collect()
1297 }
1298
1299 #[test]
1300 fn every_order_agrees_with_the_difference_of_the_one_below() {
1301 let h = 1e-5;
1302 let mut curves = every_curve();
1303 let reversed: Vec<PlanarCurve> = curves.iter().map(Reversible::reversed).collect();
1304 curves.extend(reversed);
1305 curves.retain(|c| !matches!(c.kind(), CurveKind::BSpline | CurveKind::Trimmed));
1307 for c in curves {
1308 for u in interior(&c, 6) {
1309 let at = c.derivatives_at(u, 5, T).unwrap();
1310 let below = c.derivatives_at(u + h, 4, T).unwrap();
1311 let above = c.derivatives_at(u - h, 4, T).unwrap();
1312 for order in 2..=5 {
1313 let numeric = (below[order - 1] - above[order - 1]) * (1.0 / (2.0 * h));
1314 let scale = numeric.magnitude().max(1.0);
1315 assert!(
1316 (at[order] - numeric).magnitude() <= 1e-5 * scale,
1317 "{:?} order {order} at {u}: {:?} against {numeric:?}",
1318 c.kind(),
1319 at[order]
1320 );
1321 }
1322 }
1323 }
1324 }
1325
1326 #[test]
1327 fn derivatives_agree_with_finite_differences() {
1328 let h = 1e-6;
1329 for c in every_curve() {
1330 for u in interior(&c, 8) {
1331 let d1 = c.d1_at(u, T).unwrap();
1332 let numeric = (c.point_at(u + h, T).unwrap() - c.point_at(u - h, T).unwrap())
1333 * (1.0 / (2.0 * h));
1334 assert!(
1335 (d1 - numeric).magnitude() <= 1e-5 * numeric.magnitude().max(1.0),
1336 "{:?} at {u}",
1337 c.kind()
1338 );
1339 }
1340 }
1341 }
1342
1343 #[test]
1344 fn derivatives_at_zero_returns_the_point() {
1345 for c in every_curve() {
1346 for u in interior(&c, 4) {
1347 let d = c.derivatives_at(u, 0, T).unwrap();
1348 assert_eq!(d.len(), 1);
1349 assert!(Point2::from_vector(d[0]).is_equal(c.point_at(u, T).unwrap(), T));
1350 }
1351 }
1352 }
1353
1354 #[test]
1355 fn out_of_domain_parameters_follow_periodicity() {
1356 for c in every_curve() {
1357 let (a, b) = c.domain();
1358 if c.is_periodic() {
1359 assert!(c.point_at(b + 1.0, T).is_ok(), "{:?}", c.kind());
1360 } else {
1361 assert!(c.point_at(b + 1.0, T).is_err(), "{:?}", c.kind());
1362 assert!(c.point_at(a - 1.0, T).is_err(), "{:?}", c.kind());
1363 }
1364 }
1365 }
1366
1367 #[test]
1368 fn reversal_traverses_the_same_points_backwards() {
1369 for c in every_curve() {
1370 let r = c.reversed();
1371 let (a, b) = c.domain();
1372 assert_eq!(r.domain(), (a, b), "{:?} changed its domain", c.kind());
1373 for i in 0..=8 {
1374 let t = f64::from(i) / 8.0;
1375 let forward = c.point_at(a + (b - a) * t, T).unwrap();
1376 let backward = r.point_at(a + (b - a) * (1.0 - t), T).unwrap();
1377 assert!(forward.is_equal(backward, T), "{:?} at {t}", c.kind());
1378 }
1379 }
1380 }
1381
1382 #[test]
1383 fn reversing_twice_is_the_identity() {
1384 for c in every_curve() {
1385 let twice = c.reversed().reversed();
1386 for u in interior(&c, 8) {
1387 assert!(
1388 c.point_at(u, T)
1389 .unwrap()
1390 .is_equal(twice.point_at(u, T).unwrap(), T),
1391 "{:?}",
1392 c.kind()
1393 );
1394 }
1395 }
1396 }
1397
1398 #[test]
1399 fn a_reversed_curve_heads_the_other_way() {
1400 for c in every_curve() {
1401 let r = c.reversed();
1402 let (a, b) = c.domain();
1403 let u = a + (b - a) * 0.4;
1404 let forward = tangent_angle(&c, u, T).unwrap();
1405 let backward = tangent_angle(&r, mirror(u, a, b), T).unwrap();
1406 let difference = (forward - backward).abs();
1407 assert!(
1408 (difference - core::f64::consts::PI).abs() < 1e-9,
1409 "{:?}: {forward} vs {backward}",
1410 c.kind()
1411 );
1412 }
1413 }
1414
1415 #[test]
1416 fn transforms_move_curves() {
1417 let t = Transform2::rotation(Point2::new(1.0, 1.0), 0.7);
1418 for c in every_curve() {
1419 let moved = c.transformed(&t, T).unwrap();
1420 assert_eq!(moved.kind(), c.kind());
1421 for u in interior(&c, 8) {
1422 let expected = t.apply(c.point_at(u, T).unwrap());
1423 assert!(
1424 moved.point_at(u, T).unwrap().is_equal(expected, T),
1425 "{:?} at {u}",
1426 c.kind()
1427 );
1428 }
1429 }
1430 }
1431
1432 #[test]
1433 fn a_line_segments_parameter_is_arc_length() {
1434 let l = Line2d::segment(Point2::ORIGIN, Point2::new(3.0, 4.0), T).unwrap();
1435 assert_eq!(l.domain(), (0.0, 5.0));
1436 assert!(
1437 l.point_at(2.5, T)
1438 .unwrap()
1439 .is_equal(Point2::new(1.5, 2.0), T)
1440 );
1441 assert_relative_eq!(l.d1_at(1.0, T).unwrap().magnitude(), 1.0, epsilon = 1e-15);
1442 assert!(Line2d::segment(Point2::ORIGIN, Point2::ORIGIN, T).is_err());
1443 assert!(Line2d::over(Axis2::X, 1.0, 1.0).is_err());
1444 }
1445
1446 #[test]
1447 fn a_rational_quadratic_traces_an_exact_arc() {
1448 let w = core::f64::consts::FRAC_1_SQRT_2;
1449 let control: Vec<_> = [
1450 (Point2::new(1.0, 0.0), 1.0),
1451 (Point2::new(1.0, 1.0), w),
1452 (Point2::new(0.0, 1.0), 1.0),
1453 ]
1454 .iter()
1455 .map(|(p, w)| Weighted::new(*p, *w, T).unwrap())
1456 .collect();
1457 let c = BSpline2d::rational(KnotVector::clamped_uniform(2, 3).unwrap(), control).unwrap();
1458 assert!(c.is_rational());
1459 for i in 0..=20 {
1460 let u = f64::from(i) / 20.0;
1461 assert_relative_eq!(
1462 c.point_at(u, T).unwrap().to_vector().magnitude(),
1463 1.0,
1464 epsilon = 1e-14
1465 );
1466 }
1467 }
1468
1469 #[test]
1470 fn tangent_angle_reports_the_heading() {
1471 let l: PlanarCurve = Line2d::segment(Point2::ORIGIN, Point2::new(5.0, 0.0), T)
1473 .unwrap()
1474 .into();
1475 assert_relative_eq!(tangent_angle(&l, 1.0, T).unwrap(), 0.0, epsilon = 1e-15);
1476
1477 let c: PlanarCurve =
1478 Circle2d::new(Circle2::centred(Point2::ORIGIN, 1.0, T).unwrap()).into();
1479 assert_relative_eq!(
1480 tangent_angle(&c, 0.0, T).unwrap(),
1481 core::f64::consts::FRAC_PI_2,
1482 epsilon = 1e-12
1483 );
1484 assert_relative_eq!(
1485 tangent_angle(&c, core::f64::consts::FRAC_PI_2, T).unwrap(),
1486 core::f64::consts::PI,
1487 epsilon = 1e-12
1488 );
1489 }
1490
1491 #[test]
1492 fn trimming_is_bounds_checked_and_agrees_with_its_basis() {
1493 let base: PlanarCurve = Line2d::over(Axis2::X, 0.0, 10.0).unwrap().into();
1494 assert!(Trimmed2d::new(base.clone(), 2.0, 8.0, T).is_ok());
1495 assert!(Trimmed2d::new(base.clone(), 8.0, 2.0, T).is_err());
1496 assert!(Trimmed2d::new(base.clone(), -1.0, 5.0, T).is_err());
1497
1498 let trimmed = Trimmed2d::new(base.clone(), 2.0, 8.0, T).unwrap();
1499 assert_eq!(trimmed.domain(), (2.0, 8.0));
1500 for i in 0..=6 {
1501 let u = 2.0 + 6.0 * f64::from(i) / 6.0;
1502 assert!(
1503 trimmed
1504 .point_at(u, T)
1505 .unwrap()
1506 .is_equal(base.point_at(u, T).unwrap(), T)
1507 );
1508 }
1509 assert!(trimmed.point_at(1.0, T).is_err());
1510 }
1511
1512 #[test]
1513 fn a_circle_in_parameter_space_closes_on_itself() {
1514 let c: PlanarCurve = Circle2d::new(Circle2::new(frame(), 2.0, T).unwrap()).into();
1515 assert!(c.is_closed(T) && c.is_periodic());
1516 let base = c.point_at(0.7, T).unwrap();
1517 for k in [-2.0_f64, 1.0, 3.0] {
1518 assert!(base.is_equal(c.point_at(k.mul_add(TAU, 0.7), T).unwrap(), T));
1519 }
1520 }
1521
1522 #[test]
1527 fn a_reseamed_winding_image_is_the_same_image_from_a_new_start() {
1528 let lap = Vector2::new(0.0, TAU);
1530 let ring = [
1531 Point2::new(1.0, 0.0),
1532 Point2::new(1.4, 1.5),
1533 Point2::new(0.7, 3.0),
1534 Point2::new(1.2, 4.5),
1535 Point2::new(0.9, 5.5),
1536 Point2::new(1.0, TAU),
1537 ];
1538 let knots = KnotVector::clamped_uniform(3, ring.len()).unwrap();
1539 let image = BSpline2d::new(knots, ring.to_vec(), T).unwrap();
1540 let (start, end) = image.domain();
1541 let seam = 0.35;
1542 let moved = image.reseamed_at(seam, lap, T).unwrap();
1543 let (new_start, new_end) = moved.domain();
1544 assert_relative_eq!(new_start, seam, epsilon = 1e-12);
1545 assert_relative_eq!(new_end - new_start, end - start, epsilon = 1e-12);
1546 for i in 0..=40 {
1547 let s = (end - start) * f64::from(i) / 40.0;
1548 let want = if seam + s <= end {
1549 image.point_at(seam + s, T).unwrap()
1550 } else {
1551 image.point_at(seam + s - (end - start), T).unwrap() + lap
1552 };
1553 let got = moved.point_at(new_start + s, T).unwrap();
1554 assert!(
1555 got.distance(want) < 1e-12,
1556 "at {s}: {got:?} against {want:?}"
1557 );
1558 }
1559 assert!(image.reseamed_at(seam, Vector2::new(0.0, 0.0), T).is_err());
1560 }
1561}