1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
30use ogeom_math::{
31 Axis, Circle, Ellipse, Frame, Hyperbola, KnotVector, Parabola, Point, Transform, Vector,
32 Weighted, bspline, elementary,
33};
34
35use crate::traits::{Continuity, Curve3d, CurveKind, Reversible, Transformable};
36
37pub const LINE_EXTENT: f64 = 1.0e9;
43
44#[derive(Debug, Clone, PartialEq)]
46pub enum Curve {
47 Line(LineCurve),
49 Circle(CircleCurve),
51 Ellipse(EllipseCurve),
53 Hyperbola(HyperbolaCurve),
55 Parabola(ParabolaCurve),
57 BSpline(BSplineCurve),
59 Helix(HelixCurve),
61 Trimmed(Box<TrimmedCurve>),
63 Offset(Box<OffsetCurve>),
66 OnSurface(Box<CurveOnSurface>),
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct LineCurve {
73 axis: Axis,
74 domain: (f64, f64),
75}
76
77#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct CircleCurve {
80 circle: Circle,
81 reversed: bool,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct EllipseCurve {
87 ellipse: Ellipse,
88 reversed: bool,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct HyperbolaCurve {
94 hyperbola: Hyperbola,
95 domain: (f64, f64),
96 reversed: bool,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct ParabolaCurve {
102 parabola: Parabola,
103 domain: (f64, f64),
104 reversed: bool,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
116pub struct HelixCurve {
117 frame: Frame,
118 radius: f64,
119 pitch: f64,
120 taper: f64,
124 domain: (f64, f64),
125 reversed: bool,
126}
127
128#[derive(Debug, Clone, PartialEq)]
130pub struct BSplineCurve {
131 knots: KnotVector,
132 control: Vec<Weighted<Point>>,
133 rational: bool,
134 periodic: bool,
135}
136
137#[derive(Debug, Clone, PartialEq)]
139pub struct TrimmedCurve {
140 basis: Curve,
141 domain: (f64, f64),
142 reversed: bool,
143}
144
145impl Curve {
146 #[must_use]
156 pub fn widened(&self, (lo, hi): (f64, f64)) -> Option<Self> {
157 let (a, b) = self.domain();
158 if !lo.is_finite() || !hi.is_finite() || lo > a || hi < b {
159 return None;
160 }
161 match self {
162 Self::Line(l) => Some(Self::Line(LineCurve {
163 domain: (lo, hi),
164 ..*l
165 })),
166 Self::Parabola(p) if !p.reversed => Some(Self::Parabola(ParabolaCurve {
167 domain: (lo, hi),
168 ..*p
169 })),
170 Self::Hyperbola(h) if !h.reversed => Some(Self::Hyperbola(HyperbolaCurve {
171 domain: (lo, hi),
172 ..*h
173 })),
174 Self::Helix(h) if !h.reversed => Some(Self::Helix(HelixCurve {
175 domain: (lo, hi),
176 ..*h
177 })),
178 Self::Trimmed(t) if !t.reversed => {
179 let (lo, hi) = if t.basis.is_periodic() {
180 (lo, hi)
181 } else {
182 let (ba, bb) = t.basis.domain();
183 (lo.max(ba), hi.min(bb))
184 };
185 (lo < a || hi > b).then(|| {
186 Self::Trimmed(Box::new(TrimmedCurve {
187 basis: t.basis.clone(),
188 domain: (lo, hi),
189 reversed: false,
190 }))
191 })
192 }
193 _ => None,
194 }
195 }
196}
197
198impl LineCurve {
199 #[must_use]
201 pub const fn new(axis: Axis) -> Self {
202 Self {
203 axis,
204 domain: (-LINE_EXTENT, LINE_EXTENT),
205 }
206 }
207
208 pub fn segment(from: Point, to: Point, tol: Tolerances) -> OgeomResult<Self> {
218 let axis = Axis::through(from, to, tol)?;
219 Ok(Self {
220 axis,
221 domain: (0.0, from.distance(to)),
222 })
223 }
224
225 pub fn over(axis: Axis, start: f64, end: f64) -> OgeomResult<Self> {
232 if !start.is_finite() || !end.is_finite() || end <= start {
233 ogeom_bail!(Construction, "line range [{start}, {end}] is empty");
234 }
235 Ok(Self {
236 axis,
237 domain: (start, end),
238 })
239 }
240
241 #[must_use]
243 pub const fn axis(&self) -> Axis {
244 self.axis
245 }
246}
247
248impl CircleCurve {
249 #[must_use]
251 pub const fn new(circle: Circle) -> Self {
252 Self {
253 circle,
254 reversed: false,
255 }
256 }
257
258 #[must_use]
260 pub const fn circle(&self) -> Circle {
261 self.circle
262 }
263
264 #[must_use]
270 pub const fn is_reversed(&self) -> bool {
271 self.reversed
272 }
273
274 pub fn forward(&self, tol: Tolerances) -> OgeomResult<Self> {
282 if !self.reversed {
283 return Ok(*self);
284 }
285 let frame = turned_about_x(self.circle.frame(), tol)?;
286 Ok(Self::new(Circle::new(frame, self.circle.radius(), tol)?))
287 }
288}
289
290fn turned_about_x(frame: Frame, tol: Tolerances) -> OgeomResult<Frame> {
293 Frame::from_axes(frame.origin(), frame.x(), -frame.y(), -frame.z(), tol)
294}
295
296impl EllipseCurve {
297 #[must_use]
299 pub const fn new(ellipse: Ellipse) -> Self {
300 Self {
301 ellipse,
302 reversed: false,
303 }
304 }
305
306 #[must_use]
308 pub const fn ellipse(&self) -> Ellipse {
309 self.ellipse
310 }
311
312 #[must_use]
318 pub const fn is_reversed(&self) -> bool {
319 self.reversed
320 }
321
322 pub fn forward(&self, tol: Tolerances) -> OgeomResult<Self> {
329 if !self.reversed {
330 return Ok(*self);
331 }
332 let e = self.ellipse;
333 let frame = turned_about_x(e.frame(), tol)?;
334 Ok(Self::new(Ellipse::new(
335 frame,
336 e.major_radius(),
337 e.minor_radius(),
338 tol,
339 )?))
340 }
341}
342
343impl HyperbolaCurve {
344 pub fn new(hyperbola: Hyperbola, extent: f64) -> OgeomResult<Self> {
351 if !extent.is_finite() || extent <= 0.0 {
352 ogeom_bail!(
353 Construction,
354 "hyperbola extent {extent} must be finite and positive"
355 );
356 }
357 Ok(Self {
358 hyperbola,
359 domain: (-extent, extent),
360 reversed: false,
361 })
362 }
363
364 pub fn over(hyperbola: Hyperbola, start: f64, end: f64) -> OgeomResult<Self> {
372 if !start.is_finite() || !end.is_finite() || start >= end {
373 ogeom_bail!(
374 Construction,
375 "hyperbola domain [{start}, {end}] must be finite and increasing"
376 );
377 }
378 Ok(Self {
379 hyperbola,
380 domain: (start, end),
381 reversed: false,
382 })
383 }
384
385 #[must_use]
387 pub const fn hyperbola(&self) -> Hyperbola {
388 self.hyperbola
389 }
390
391 #[must_use]
397 pub const fn is_reversed(&self) -> bool {
398 self.reversed
399 }
400}
401
402impl ParabolaCurve {
403 pub fn new(parabola: Parabola, extent: f64) -> OgeomResult<Self> {
410 if !extent.is_finite() || extent <= 0.0 {
411 ogeom_bail!(
412 Construction,
413 "parabola extent {extent} must be finite and positive"
414 );
415 }
416 Ok(Self {
417 parabola,
418 domain: (-extent, extent),
419 reversed: false,
420 })
421 }
422
423 pub fn over(parabola: Parabola, start: f64, end: f64) -> OgeomResult<Self> {
430 if !start.is_finite() || !end.is_finite() || start >= end {
431 ogeom_bail!(
432 Construction,
433 "parabola domain [{start}, {end}] must be finite and increasing"
434 );
435 }
436 Ok(Self {
437 parabola,
438 domain: (start, end),
439 reversed: false,
440 })
441 }
442
443 #[must_use]
445 pub const fn parabola(&self) -> Parabola {
446 self.parabola
447 }
448
449 #[must_use]
455 pub const fn is_reversed(&self) -> bool {
456 self.reversed
457 }
458}
459
460impl HelixCurve {
461 pub fn new(frame: Frame, radius: f64, pitch: f64, turns: f64) -> OgeomResult<Self> {
470 if !turns.is_finite() || turns <= 0.0 {
471 ogeom_bail!(Construction, "a helix over {turns} turns is not a curve");
472 }
473 Self::over(frame, radius, pitch, 0.0, core::f64::consts::TAU * turns)
474 }
475
476 pub fn over(frame: Frame, radius: f64, pitch: f64, start: f64, end: f64) -> OgeomResult<Self> {
483 if !radius.is_finite() || radius <= 0.0 {
484 ogeom_bail!(
485 Construction,
486 "helix radius {radius} must be finite and positive"
487 );
488 }
489 if !pitch.is_finite() || pitch == 0.0 {
490 ogeom_bail!(
491 Construction,
492 "helix pitch {pitch} must be finite and non-zero; a zero pitch is a circle"
493 );
494 }
495 if !start.is_finite() || !end.is_finite() || start >= end {
496 ogeom_bail!(
497 Construction,
498 "helix domain [{start}, {end}] must be finite and increasing"
499 );
500 }
501 Ok(Self {
502 frame,
503 radius,
504 pitch,
505 taper: 0.0,
506 domain: (start, end),
507 reversed: false,
508 })
509 }
510
511 pub fn conical(
522 frame: Frame,
523 radius: f64,
524 pitch: f64,
525 taper: f64,
526 start: f64,
527 end: f64,
528 ) -> OgeomResult<Self> {
529 let mut helix = Self::over(frame, radius, pitch, start, end)?;
530 if !taper.is_finite() {
531 ogeom_bail!(Construction, "helix taper {taper} must be finite");
532 }
533 let slope = taper / core::f64::consts::TAU;
534 let (ra, rb) = (slope.mul_add(start, radius), slope.mul_add(end, radius));
535 if ra <= 0.0 || rb <= 0.0 {
536 ogeom_bail!(
537 Construction,
538 "the helix radius runs non-positive on [{start}, {end}]; \
539 past the apex there is no cone to wind"
540 );
541 }
542 helix.taper = taper;
543 Ok(helix)
544 }
545
546 #[must_use]
548 pub const fn frame(&self) -> &Frame {
549 &self.frame
550 }
551
552 #[must_use]
554 pub const fn radius(&self) -> f64 {
555 self.radius
556 }
557
558 #[must_use]
560 pub const fn pitch(&self) -> f64 {
561 self.pitch
562 }
563
564 #[must_use]
566 pub const fn taper(&self) -> f64 {
567 self.taper
568 }
569
570 #[must_use]
572 pub const fn is_reversed(&self) -> bool {
573 self.reversed
574 }
575
576 #[must_use]
584 pub fn arc_length(&self, from: f64, to: f64) -> f64 {
585 let tau = core::f64::consts::TAU;
586 let (rise, slope) = (self.pitch / tau, self.taper / tau);
587 if slope == 0.0 {
588 return (to - from).abs() * self.radius.hypot(rise);
589 }
590 let k = slope.hypot(rise);
591 let primitive = |r: f64| f64::midpoint(r * r.hypot(k), k * k * (r / k).asinh());
592 let (r0, r1) = (
593 slope.mul_add(from, self.radius),
594 slope.mul_add(to, self.radius),
595 );
596 ((primitive(r1) - primitive(r0)) / slope).abs()
597 }
598
599 fn at(&self, t: f64) -> (Point, Vector, Vector, Vector) {
601 let (sin, cos) = t.sin_cos();
602 let x = self.frame.x().vector();
603 let y = self.frame.y().vector();
604 let z = self.frame.z().vector();
605 let rise = self.pitch / core::f64::consts::TAU;
606 let slope = self.taper / core::f64::consts::TAU;
607 let r = slope.mul_add(t, self.radius);
608 let point = self.frame.origin() + x * (r * cos) + y * (r * sin) + z * (rise * t);
609 let d1 = x * slope.mul_add(cos, -(r * sin)) + y * slope.mul_add(sin, r * cos) + z * rise;
610 let d2 = x * (2.0 * slope).mul_add(-sin, -(r * cos))
611 + y * (2.0 * slope).mul_add(cos, -(r * sin));
612 let d3 =
613 x * (3.0 * slope).mul_add(-cos, r * sin) + y * (3.0 * slope).mul_add(-sin, -(r * cos));
614 (point, d1, d2, d3)
615 }
616}
617
618impl Curve3d for HelixCurve {
619 fn domain(&self) -> (f64, f64) {
620 self.domain
621 }
622
623 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
624 let u = self.normalize_parameter(u, tol)?;
625 let t = if self.reversed {
626 mirror(u, self.domain.0, self.domain.1)
627 } else {
628 u
629 };
630 Ok(self.at(t).0)
631 }
632
633 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
634 Ok(self.derivatives_at(u, 1, tol)?[1])
635 }
636
637 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
638 let u = self.normalize_parameter(u, tol)?;
639 let t = if self.reversed {
640 mirror(u, self.domain.0, self.domain.1)
641 } else {
642 u
643 };
644 let (point, _, _, _) = self.at(t);
645 let (sin, cos) = t.sin_cos();
650 let x = self.frame.x().vector();
651 let y = self.frame.y().vector();
652 let z = self.frame.z().vector();
653 let rise = self.pitch / core::f64::consts::TAU;
654 let slope = self.taper / core::f64::consts::TAU;
655 let r = slope.mul_add(t, self.radius);
656 let cos_k = |k: usize| [cos, -sin, -cos, sin][k % 4];
657 let sin_k = |k: usize| [sin, cos, -sin, -cos][k % 4];
658 let sign = if self.reversed { -1.0 } else { 1.0 };
659 let mut out = vec![point.to_vector()];
660 let mut factor = 1.0;
661 for k in 1..=n {
662 factor *= sign;
663 #[allow(clippy::cast_precision_loss)]
664 let ks = k as f64 * slope;
665 let mut d = x * ks.mul_add(cos_k(k - 1), r * cos_k(k))
666 + y * ks.mul_add(sin_k(k - 1), r * sin_k(k));
667 if k == 1 {
668 d += z * rise;
669 }
670 out.push(d * factor);
671 }
672 Ok(out)
673 }
674
675 fn kind(&self) -> CurveKind {
676 CurveKind::Helix
677 }
678
679 fn continuity(&self) -> Continuity {
680 Continuity::CInfinity
681 }
682
683 fn is_closed(&self, _tol: Tolerances) -> bool {
684 false
685 }
686
687 fn is_periodic(&self) -> bool {
688 false
689 }
690}
691
692#[derive(Debug, Clone, PartialEq)]
702pub struct OffsetCurve {
703 basis: Curve,
704 distance: f64,
705 reference: ogeom_math::Direction,
706}
707
708impl OffsetCurve {
709 pub fn new(basis: Curve, distance: f64, reference: ogeom_math::Direction) -> OgeomResult<Self> {
716 if !distance.is_finite() || distance == 0.0 {
717 ogeom_bail!(
718 Construction,
719 "an offset of {distance} is not a displacement"
720 );
721 }
722 Ok(Self {
723 basis,
724 distance,
725 reference,
726 })
727 }
728
729 #[must_use]
731 pub const fn basis(&self) -> &Curve {
732 &self.basis
733 }
734
735 #[must_use]
737 pub const fn distance(&self) -> f64 {
738 self.distance
739 }
740
741 #[must_use]
743 pub const fn reference(&self) -> ogeom_math::Direction {
744 self.reference
745 }
746
747 fn direction_and_slope(&self, t: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
749 let d = self.basis.derivatives_at(t, 2, tol)?;
750 let v = self.reference.vector();
751 let w = d[1].cross(v);
752 let m = w.magnitude();
753 if m <= tol.confusion() * d[1].magnitude().max(1.0) {
754 ogeom_bail!(
755 Construction,
756 "the tangent at {t} runs along the reference; the offset direction is undefined"
757 );
758 }
759 let n = w / m;
760 let wp = d[2].cross(v);
761 let np = (wp - n * n.dot(wp)) / m;
762 Ok((n, np))
763 }
764}
765
766impl Curve3d for OffsetCurve {
767 fn domain(&self) -> (f64, f64) {
768 self.basis.domain()
769 }
770
771 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
772 let base = self.basis.point_at(u, tol)?;
773 let (n, _) = self.direction_and_slope(u, tol)?;
774 Ok(base + n * self.distance)
775 }
776
777 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
778 let d1 = self.basis.d1_at(u, tol)?;
779 let (_, np) = self.direction_and_slope(u, tol)?;
780 Ok(d1 + np * self.distance)
781 }
782
783 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
784 if n >= 2 {
785 ogeom_bail!(
786 Construction,
787 "an offset curve's second derivative needs its basis's third, which the \
788 vocabulary does not carry"
789 );
790 }
791 let mut out = vec![self.point_at(u, tol)?.to_vector()];
792 if n >= 1 {
793 out.push(self.d1_at(u, tol)?);
794 }
795 Ok(out)
796 }
797
798 fn kind(&self) -> CurveKind {
799 CurveKind::Offset
800 }
801
802 fn continuity(&self) -> Continuity {
803 match self.basis.continuity() {
804 Continuity::CInfinity => Continuity::CInfinity,
805 Continuity::C2 | Continuity::G2 => Continuity::C1,
806 Continuity::C1 | Continuity::G1 | Continuity::C0 => Continuity::C0,
807 }
808 }
809
810 fn is_closed(&self, tol: Tolerances) -> bool {
811 self.basis.is_closed(tol)
812 }
813
814 fn is_periodic(&self) -> bool {
815 self.basis.is_periodic()
816 }
817}
818
819#[derive(Debug, Clone, PartialEq)]
826pub struct CurveOnSurface {
827 pcurve: crate::curve2d::PlanarCurve,
828 surface: crate::surface::SurfaceGeometry,
829}
830
831impl CurveOnSurface {
832 #[must_use]
834 pub const fn new(
835 pcurve: crate::curve2d::PlanarCurve,
836 surface: crate::surface::SurfaceGeometry,
837 ) -> Self {
838 Self { pcurve, surface }
839 }
840
841 #[must_use]
843 pub const fn pcurve(&self) -> &crate::curve2d::PlanarCurve {
844 &self.pcurve
845 }
846
847 #[must_use]
849 pub const fn surface(&self) -> &crate::surface::SurfaceGeometry {
850 &self.surface
851 }
852}
853
854impl Curve3d for CurveOnSurface {
855 fn domain(&self) -> (f64, f64) {
856 use crate::traits::Curve2d as _;
857 self.pcurve.domain()
858 }
859
860 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
861 use crate::traits::{Curve2d as _, Surface as _};
862 let p = self.pcurve.point_at(u, tol)?;
863 self.surface.point_at(p.x, p.y, tol)
864 }
865
866 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
867 Ok(self.derivatives_at(u, 1, tol)?[1])
868 }
869
870 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
871 use crate::traits::{Curve2d as _, Surface as _};
872 if n >= 3 {
873 ogeom_bail!(
874 Construction,
875 "a surface curve's third derivative needs the surface's, which the \
876 vocabulary does not carry"
877 );
878 }
879 let d = self.pcurve.derivatives_at(u, n.max(2), tol)?;
880 let at = d[0];
881 let point = self.surface.point_at(at.x, at.y, tol)?;
882 let mut out = vec![point.to_vector()];
883 if n >= 1 {
884 let (su, sv) = self.surface.d1_at(at.x, at.y, tol)?;
885 out.push(su * d[1].x + sv * d[1].y);
886 if n >= 2 {
887 let (suu, suv, svv) = self.surface.d2_at(at.x, at.y, tol)?;
888 let second = suu * (d[1].x * d[1].x)
891 + suv * (2.0 * d[1].x * d[1].y)
892 + svv * (d[1].y * d[1].y)
893 + su * d[2].x
894 + sv * d[2].y;
895 out.push(second);
896 }
897 }
898 Ok(out)
899 }
900
901 fn kind(&self) -> CurveKind {
902 CurveKind::OnSurface
903 }
904
905 fn continuity(&self) -> Continuity {
906 use crate::traits::Surface as _;
907 self.pcurve_continuity().min(self.surface.continuity())
909 }
910
911 fn is_closed(&self, tol: Tolerances) -> bool {
912 use crate::traits::Curve2d as _;
913 self.pcurve.is_closed(tol)
914 }
915
916 fn is_periodic(&self) -> bool {
917 use crate::traits::Curve2d as _;
918 self.pcurve.is_periodic()
919 }
920}
921
922impl CurveOnSurface {
923 fn pcurve_continuity(&self) -> Continuity {
924 Continuity::C2
928 }
929}
930
931impl BSplineCurve {
932 pub fn new(knots: KnotVector, control: Vec<Point>, tol: Tolerances) -> OgeomResult<Self> {
939 let weighted = control
940 .into_iter()
941 .map(|p| Weighted::new(p, 1.0, tol))
942 .collect::<OgeomResult<Vec<_>>>()?;
943 Self::rational(knots, weighted)
944 }
945
946 pub fn rational(knots: KnotVector, control: Vec<Weighted<Point>>) -> OgeomResult<Self> {
953 if control.len() != knots.control_point_count() {
954 ogeom_bail!(
955 Dimension,
956 "knot vector describes {} control points, got {}",
957 knots.control_point_count(),
958 control.len()
959 );
960 }
961 let first = control[0].weight;
964 let rational = control
965 .iter()
966 .any(|w| (w.weight - first).abs() > 1e-12 * first.abs());
967 Ok(Self {
968 knots,
969 control,
970 rational,
971 periodic: false,
972 })
973 }
974
975 pub fn periodic(control: &[Point], degree: usize, tol: Tolerances) -> OgeomResult<Self> {
987 if degree == 0 {
988 ogeom_bail!(Construction, "a curve needs a degree of at least one");
989 }
990 if control.len() <= degree {
991 ogeom_bail!(
992 Construction,
993 "a periodic ring of {} points cannot carry degree {degree}",
994 control.len()
995 );
996 }
997 let n = control.len();
998 let mut wrapped: Vec<Point> = Vec::with_capacity(n + degree);
999 wrapped.extend_from_slice(control);
1000 wrapped.extend_from_slice(&control[..degree]);
1001 #[allow(clippy::cast_precision_loss)]
1002 let knots: Vec<f64> = (0..wrapped.len() + degree + 1).map(|i| i as f64).collect();
1003 let mut built = Self::new(KnotVector::new(knots, degree)?, wrapped, tol)?;
1004 built.periodic = true;
1005 Ok(built)
1006 }
1007
1008 pub fn periodic_from_parts(
1017 knots: KnotVector,
1018 control: Vec<Weighted<Point>>,
1019 tol: Tolerances,
1020 ) -> OgeomResult<Self> {
1021 let degree = knots.degree();
1022 if control.len() <= degree {
1023 ogeom_bail!(
1024 Construction,
1025 "a periodic curve of {} controls cannot carry degree {degree}",
1026 control.len()
1027 );
1028 }
1029 let n = control.len() - degree;
1030 for i in 0..degree {
1031 let (a, b) = (control[i], control[n + i]);
1032 if !a.point().is_equal(b.point(), tol) || (a.weight - b.weight).abs() > 1e-12 {
1033 ogeom_bail!(
1034 Construction,
1035 "a periodic curve's trailing controls must repeat its \
1036 leading ones; control {} does not",
1037 n + i
1038 );
1039 }
1040 }
1041 let mut built = Self::rational(knots, control)?;
1042 built.periodic = true;
1043 Ok(built)
1044 }
1045
1046 #[must_use]
1048 pub const fn knots(&self) -> &KnotVector {
1049 &self.knots
1050 }
1051
1052 #[must_use]
1054 pub fn control_points(&self) -> &[Weighted<Point>] {
1055 &self.control
1056 }
1057
1058 #[must_use]
1060 pub const fn is_rational(&self) -> bool {
1061 self.rational
1062 }
1063
1064 #[must_use]
1066 pub const fn degree(&self) -> usize {
1067 self.knots.degree()
1068 }
1069
1070 pub fn with_knot_inserted(&self, u: f64, count: usize, tol: Tolerances) -> OgeomResult<Self> {
1076 let (knots, control) = bspline::insert_knot(&self.knots, &self.control, u, count, tol)?;
1077 Ok(Self {
1078 knots,
1079 control,
1080 rational: self.rational,
1081 periodic: self.periodic,
1082 })
1083 }
1084
1085 pub fn elevated(&self, tol: Tolerances) -> OgeomResult<Self> {
1091 let (knots, control) = bspline::elevate_degree(&self.knots, &self.control, tol)?;
1092 Ok(Self {
1093 knots,
1094 control,
1095 rational: self.rational,
1096 periodic: self.periodic,
1097 })
1098 }
1099
1100 pub fn reseamed_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Self> {
1112 if self.periodic {
1113 ogeom_bail!(Construction, "a periodic curve has no seam to move");
1114 }
1115 let (start, end) = self.domain();
1116 let (head, tail) = (self.point_at(start, tol)?, self.point_at(end, tol)?);
1117 if head.distance(tail) > tol.confusion() * 1e3 {
1121 ogeom_bail!(
1122 Construction,
1123 "the curve does not close: its ends are {:.3e} apart",
1124 head.distance(tail)
1125 );
1126 }
1127 let (before, after) = self.split_at(u, tol)?;
1128 let (knots, control) = bspline::join(
1129 &(after.knots, after.control),
1130 &(before.knots, before.control),
1131 )?;
1132 Ok(Self {
1133 knots,
1134 control,
1135 rational: self.rational,
1136 periodic: self.periodic,
1137 })
1138 }
1139
1140 pub fn extended(
1156 &self,
1157 at_end: bool,
1158 length: f64,
1159 continuity: usize,
1160 tol: Tolerances,
1161 ) -> OgeomResult<Self> {
1162 if !(length > 0.0 && length.is_finite()) {
1163 ogeom_bail!(
1164 Construction,
1165 "an extension needs a positive length; got {length}"
1166 );
1167 }
1168 let speed = self.end_speed(at_end, tol)?;
1169 let build = |span: f64| -> OgeomResult<Self> {
1170 let (knots, control) =
1171 bspline::extend(&self.knots, &self.control, at_end, span, continuity, tol)?;
1172 Ok(Self {
1173 knots,
1174 control,
1175 rational: self.rational,
1176 periodic: self.periodic,
1177 })
1178 };
1179 let run = |curve: &Self, span: f64| -> OgeomResult<f64> {
1183 let (lo, hi) = curve.domain();
1184 let (a, b) = if at_end {
1185 (hi - span, hi)
1186 } else {
1187 (lo, lo + span)
1188 };
1189 curve.length_over((a, b), tol)
1190 };
1191 let mut s0 = length / speed;
1192 let mut c0 = build(s0)?;
1193 let mut l0 = run(&c0, s0)? - length;
1194 let mut s1 = s0 * 1.1;
1195 for _ in 0..40 {
1196 if l0.abs() <= tol.confusion() {
1197 break;
1198 }
1199 let c1 = build(s1)?;
1200 let l1 = run(&c1, s1)? - length;
1201 let next = if (l1 - l0).abs() > f64::EPSILON {
1202 s1 - l1 * (s1 - s0) / (l1 - l0)
1203 } else {
1204 s1
1205 };
1206 (s0, c0, l0) = (s1, c1, l1);
1207 s1 = if next > 0.0 { next } else { s0 * 0.5 };
1208 }
1209 Ok(c0)
1210 }
1211
1212 pub fn extended_to(
1223 &self,
1224 at_end: bool,
1225 target: Point,
1226 continuity: usize,
1227 tol: Tolerances,
1228 ) -> OgeomResult<Self> {
1229 let speed = self.end_speed(at_end, tol)?;
1230 let (lo, hi) = self.domain();
1231 let at = if at_end { hi } else { lo };
1232 let from = self.point_at(at, tol)?;
1233 let gap = from.distance(target);
1234 if gap <= tol.confusion() {
1235 ogeom_bail!(Construction, "the curve already ends at the point");
1236 }
1237 let end = if at_end {
1238 self.control[self.control.len() - 1]
1239 } else {
1240 self.control[0]
1241 };
1242 let weighted = Weighted::new(target, end.weight, tol)?;
1243 let (knots, control) = bspline::extend_to(
1244 &self.knots,
1245 &self.control,
1246 at_end,
1247 weighted,
1248 gap / speed,
1249 continuity,
1250 tol,
1251 )?;
1252 let rational = self.rational;
1253 Ok(Self {
1254 knots,
1255 control,
1256 rational,
1257 periodic: false,
1258 })
1259 }
1260
1261 fn end_speed(&self, at_end: bool, tol: Tolerances) -> OgeomResult<f64> {
1263 if self.periodic {
1264 ogeom_bail!(Construction, "a periodic curve has no end to continue from");
1265 }
1266 let (lo, hi) = self.domain();
1267 let at = if at_end { hi } else { lo };
1268 let speed = self.d1_at(at, tol)?.magnitude();
1269 if speed <= tol.confusion() {
1270 ogeom_bail!(
1271 Construction,
1272 "the curve stands still at its end; there is no direction to continue in"
1273 );
1274 }
1275 Ok(speed)
1276 }
1277
1278 fn length_over(&self, range: (f64, f64), tol: Tolerances) -> OgeomResult<f64> {
1281 const NODES: [(f64, f64); 5] = [
1282 (0.0, 0.568_888_888_888_888_9),
1283 (-0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
1284 (0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
1285 (-0.906_179_845_938_664, 0.236_926_885_056_189_1),
1286 (0.906_179_845_938_664, 0.236_926_885_056_189_1),
1287 ];
1288 let mut breaks: Vec<f64> = vec![range.0];
1289 breaks.extend(
1290 self.knots
1291 .distinct()
1292 .into_iter()
1293 .map(|(k, _)| k)
1294 .filter(|k| *k > range.0 && *k < range.1),
1295 );
1296 breaks.push(range.1);
1297 let mut total = 0.0;
1298 for w in breaks.windows(2) {
1299 for part in 0..8 {
1301 let a = w[0] + (w[1] - w[0]) * f64::from(part) / 8.0;
1302 let b = w[0] + (w[1] - w[0]) * f64::from(part + 1) / 8.0;
1303 let (mid, half) = (0.5 * (a + b), 0.5 * (b - a));
1304 for (x, weight) in NODES {
1305 total += weight * half * self.d1_at(mid + half * x, tol)?.magnitude();
1306 }
1307 }
1308 }
1309 Ok(total)
1310 }
1311
1312 pub fn segment(&self, range: (f64, f64), tol: Tolerances) -> OgeomResult<Self> {
1320 let (a, b) = self.knots.domain();
1321 let eps = tol.parametric();
1322 if range.1 <= range.0 + eps || range.0 < a - eps || range.1 > b + eps {
1323 ogeom_bail!(
1324 Domain,
1325 "[{}, {}] is no piece of [{a}, {b}]",
1326 range.0,
1327 range.1
1328 );
1329 }
1330 let mut piece = Self {
1331 knots: self.knots.clone(),
1332 control: self.control.clone(),
1333 rational: self.rational,
1334 periodic: false,
1335 };
1336 if range.0 > a + eps {
1337 piece = piece.split_at(range.0, tol)?.1;
1338 }
1339 if range.1 < b - eps {
1340 piece = piece.split_at(range.1, tol)?.0;
1341 }
1342 Ok(piece)
1343 }
1344
1345 pub fn split_at(&self, u: f64, tol: Tolerances) -> OgeomResult<(Self, Self)> {
1351 let ((lk, lc), (rk, rc)) = bspline::split(&self.knots, &self.control, u, tol)?;
1352 Ok((
1353 Self {
1354 knots: lk,
1355 control: lc,
1356 rational: self.rational,
1357 periodic: self.periodic,
1358 },
1359 Self {
1360 knots: rk,
1361 control: rc,
1362 rational: self.rational,
1363 periodic: self.periodic,
1364 },
1365 ))
1366 }
1367}
1368
1369impl TrimmedCurve {
1370 pub fn new(basis: Curve, start: f64, end: f64, tol: Tolerances) -> OgeomResult<Self> {
1377 let (a, b) = basis.domain();
1378 if !start.is_finite() || !end.is_finite() || end <= start + tol.parametric() {
1379 ogeom_bail!(Domain, "trim range [{start}, {end}] is empty");
1380 }
1381 if !basis.is_periodic() && (start < a - tol.parametric() || end > b + tol.parametric()) {
1382 ogeom_bail!(
1383 Domain,
1384 "trim range [{start}, {end}] leaves the basis domain [{a}, {b}]"
1385 );
1386 }
1387 Ok(Self {
1388 basis,
1389 domain: (start, end),
1390 reversed: false,
1391 })
1392 }
1393
1394 #[must_use]
1396 pub const fn basis(&self) -> &Curve {
1397 &self.basis
1398 }
1399
1400 #[must_use]
1406 pub const fn is_reversed(&self) -> bool {
1407 self.reversed
1408 }
1409
1410 fn basis_parameter(&self, u: f64, tol: Tolerances) -> OgeomResult<f64> {
1412 let u = self.normalize_parameter(u, tol)?;
1413 Ok(if self.reversed {
1414 mirror(u, self.domain.0, self.domain.1)
1415 } else {
1416 u
1417 })
1418 }
1419}
1420
1421fn mirror(u: f64, a: f64, b: f64) -> f64 {
1427 a + b - u
1428}
1429
1430fn cycled(
1435 point: Vector,
1436 d1: Vector,
1437 d2: Vector,
1438 n: usize,
1439 period: usize,
1440 sign: f64,
1441) -> Vec<Vector> {
1442 let mut out = vec![point];
1443 let mut factor = 1.0;
1444 for order in 1..=n {
1445 factor *= sign;
1446 let d = match (order - 1) % period {
1447 0 => d1,
1448 1 => d2,
1449 2 => -d1,
1450 _ => -d2,
1451 };
1452 out.push(d * factor);
1453 }
1454 out
1455}
1456
1457impl Curve3d for LineCurve {
1458 fn domain(&self) -> (f64, f64) {
1459 self.domain
1460 }
1461
1462 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1463 let u = self.normalize_parameter(u, tol)?;
1464 Ok(self.axis.point_at(u))
1465 }
1466
1467 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1468 self.normalize_parameter(u, tol)?;
1469 Ok(self.axis.direction.vector())
1470 }
1471
1472 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1473 let p = self.point_at(u, tol)?;
1474 let mut out = vec![p.to_vector(), self.axis.direction.vector()];
1475 out.resize(n + 1, Vector::ZERO);
1476 out.truncate(n + 1);
1477 Ok(out)
1478 }
1479
1480 fn kind(&self) -> CurveKind {
1481 CurveKind::Line
1482 }
1483
1484 fn continuity(&self) -> Continuity {
1485 Continuity::CInfinity
1486 }
1487
1488 fn is_closed(&self, _tol: Tolerances) -> bool {
1489 false
1490 }
1491
1492 fn is_periodic(&self) -> bool {
1493 false
1494 }
1495}
1496
1497impl Curve3d for CircleCurve {
1498 fn domain(&self) -> (f64, f64) {
1499 (0.0, core::f64::consts::TAU)
1500 }
1501
1502 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1503 let u = self.normalize_parameter(u, tol)?;
1504 let angle = if self.reversed { -u } else { u };
1505 Ok(elementary::circle_at(&self.circle, angle).point)
1506 }
1507
1508 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1509 Ok(self.derivatives_at(u, 1, tol)?[1])
1510 }
1511
1512 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1513 let u = self.normalize_parameter(u, tol)?;
1514 let angle = if self.reversed { -u } else { u };
1515 let c = elementary::circle_at(&self.circle, angle);
1516 let sign = if self.reversed { -1.0 } else { 1.0 };
1517 Ok(cycled(c.point.to_vector(), c.d1, c.d2, n, 4, sign))
1518 }
1519
1520 fn kind(&self) -> CurveKind {
1521 CurveKind::Circle
1522 }
1523
1524 fn continuity(&self) -> Continuity {
1525 Continuity::CInfinity
1526 }
1527
1528 fn is_closed(&self, _tol: Tolerances) -> bool {
1529 true
1530 }
1531
1532 fn is_periodic(&self) -> bool {
1533 true
1534 }
1535}
1536
1537impl Curve3d for EllipseCurve {
1538 fn domain(&self) -> (f64, f64) {
1539 (0.0, core::f64::consts::TAU)
1540 }
1541
1542 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1543 let u = self.normalize_parameter(u, tol)?;
1544 let angle = if self.reversed { -u } else { u };
1545 Ok(elementary::ellipse_at(&self.ellipse, angle).point)
1546 }
1547
1548 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1549 Ok(self.derivatives_at(u, 1, tol)?[1])
1550 }
1551
1552 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1553 let u = self.normalize_parameter(u, tol)?;
1554 let angle = if self.reversed { -u } else { u };
1555 let c = elementary::ellipse_at(&self.ellipse, angle);
1556 let sign = if self.reversed { -1.0 } else { 1.0 };
1557 Ok(cycled(c.point.to_vector(), c.d1, c.d2, n, 4, sign))
1558 }
1559
1560 fn kind(&self) -> CurveKind {
1561 CurveKind::Ellipse
1562 }
1563
1564 fn continuity(&self) -> Continuity {
1565 Continuity::CInfinity
1566 }
1567
1568 fn is_closed(&self, _tol: Tolerances) -> bool {
1569 true
1570 }
1571
1572 fn is_periodic(&self) -> bool {
1573 true
1574 }
1575}
1576
1577impl Curve3d for HyperbolaCurve {
1578 fn domain(&self) -> (f64, f64) {
1579 self.domain
1580 }
1581
1582 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1583 let u = self.normalize_parameter(u, tol)?;
1584 let t = if self.reversed {
1585 mirror(u, self.domain.0, self.domain.1)
1586 } else {
1587 u
1588 };
1589 Ok(elementary::hyperbola_at(&self.hyperbola, t).point)
1590 }
1591
1592 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1593 Ok(self.derivatives_at(u, 1, tol)?[1])
1594 }
1595
1596 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1597 let u = self.normalize_parameter(u, tol)?;
1598 let t = if self.reversed {
1599 mirror(u, self.domain.0, self.domain.1)
1600 } else {
1601 u
1602 };
1603 let c = elementary::hyperbola_at(&self.hyperbola, t);
1604 let sign = if self.reversed { -1.0 } else { 1.0 };
1605 Ok(cycled(c.point.to_vector(), c.d1, c.d2, n, 2, sign))
1606 }
1607
1608 fn kind(&self) -> CurveKind {
1609 CurveKind::Hyperbola
1610 }
1611
1612 fn continuity(&self) -> Continuity {
1613 Continuity::CInfinity
1614 }
1615
1616 fn is_closed(&self, _tol: Tolerances) -> bool {
1617 false
1618 }
1619
1620 fn is_periodic(&self) -> bool {
1621 false
1622 }
1623}
1624
1625impl Curve3d for ParabolaCurve {
1626 fn domain(&self) -> (f64, f64) {
1627 self.domain
1628 }
1629
1630 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1631 let u = self.normalize_parameter(u, tol)?;
1632 let t = if self.reversed {
1633 mirror(u, self.domain.0, self.domain.1)
1634 } else {
1635 u
1636 };
1637 Ok(elementary::parabola_at(&self.parabola, t).point)
1638 }
1639
1640 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1641 Ok(self.derivatives_at(u, 1, tol)?[1])
1642 }
1643
1644 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1645 let u = self.normalize_parameter(u, tol)?;
1646 let t = if self.reversed {
1647 mirror(u, self.domain.0, self.domain.1)
1648 } else {
1649 u
1650 };
1651 let c = elementary::parabola_at(&self.parabola, t);
1652 let sign = if self.reversed { -1.0 } else { 1.0 };
1653 let mut out = vec![c.point.to_vector(), c.d1 * sign, c.d2];
1655 out.resize(n.max(2) + 1, Vector::ZERO);
1656 out.truncate(n + 1);
1657 Ok(out)
1658 }
1659
1660 fn kind(&self) -> CurveKind {
1661 CurveKind::Parabola
1662 }
1663
1664 fn continuity(&self) -> Continuity {
1665 Continuity::CInfinity
1666 }
1667
1668 fn is_closed(&self, _tol: Tolerances) -> bool {
1669 false
1670 }
1671
1672 fn is_periodic(&self) -> bool {
1673 false
1674 }
1675}
1676
1677impl Curve3d for BSplineCurve {
1678 fn domain(&self) -> (f64, f64) {
1679 self.knots.domain()
1680 }
1681
1682 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1683 let u = self.normalize_parameter(u, tol)?;
1684 if self.rational {
1685 bspline::evaluate_rational(&self.knots, &self.control, u, tol)
1686 } else {
1687 Ok(bspline::evaluate(&self.knots, &self.control, u, tol)?.point())
1690 }
1691 }
1692
1693 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1694 Ok(self.derivatives_at(u, 1, tol)?[1])
1695 }
1696
1697 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1698 let u = self.normalize_parameter(u, tol)?;
1699 let points = bspline::rational_derivatives(&self.knots, &self.control, u, n, tol)?;
1700 Ok(points.into_iter().map(Point::to_vector).collect())
1701 }
1702
1703 fn kind(&self) -> CurveKind {
1704 CurveKind::BSpline
1705 }
1706
1707 fn continuity(&self) -> Continuity {
1719 let degree = self.knots.degree();
1720 let (a, b) = self.knots.domain();
1721 let worst = self
1722 .knots
1723 .distinct()
1724 .into_iter()
1725 .filter(|(v, _)| *v > a && *v < b)
1726 .map(|(_, m)| m)
1727 .max();
1728 match worst {
1729 None => Continuity::CInfinity,
1730 Some(m) => match degree.saturating_sub(m) {
1731 0 => Continuity::C0,
1732 1 => Continuity::C1,
1733 _ => Continuity::C2,
1734 },
1735 }
1736 }
1737
1738 fn is_closed(&self, tol: Tolerances) -> bool {
1741 self.periodic
1742 || match (self.start(tol), self.end(tol)) {
1743 (Ok(a), Ok(b)) => a.is_equal(b, tol),
1744 _ => false,
1745 }
1746 }
1747
1748 fn is_periodic(&self) -> bool {
1749 self.periodic
1750 }
1751}
1752
1753impl Curve3d for TrimmedCurve {
1754 fn domain(&self) -> (f64, f64) {
1755 self.domain
1756 }
1757
1758 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1759 self.basis.point_at(self.basis_parameter(u, tol)?, tol)
1760 }
1761
1762 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1763 let d = self.basis.d1_at(self.basis_parameter(u, tol)?, tol)?;
1764 Ok(if self.reversed { -d } else { d })
1765 }
1766
1767 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1768 let t = self.basis_parameter(u, tol)?;
1769 let mut out = self.basis.derivatives_at(t, n, tol)?;
1770 if self.reversed {
1771 for (order, d) in out.iter_mut().enumerate() {
1774 if order % 2 == 1 {
1775 *d = -*d;
1776 }
1777 }
1778 }
1779 Ok(out)
1780 }
1781
1782 fn kind(&self) -> CurveKind {
1783 CurveKind::Trimmed
1784 }
1785
1786 fn continuity(&self) -> Continuity {
1787 self.basis.continuity()
1788 }
1789
1790 fn is_closed(&self, tol: Tolerances) -> bool {
1791 match (self.start(tol), self.end(tol)) {
1792 (Ok(a), Ok(b)) => a.is_equal(b, tol),
1793 _ => false,
1794 }
1795 }
1796
1797 fn is_periodic(&self) -> bool {
1798 false
1799 }
1800}
1801
1802macro_rules! dispatch {
1804 ($self:ident, $c:ident => $body:expr) => {
1805 match $self {
1806 Self::Line($c) => $body,
1807 Self::Circle($c) => $body,
1808 Self::Ellipse($c) => $body,
1809 Self::Hyperbola($c) => $body,
1810 Self::Parabola($c) => $body,
1811 Self::BSpline($c) => $body,
1812 Self::Helix($c) => $body,
1813 Self::Trimmed($c) => $body,
1814 Self::Offset($c) => $body,
1815 Self::OnSurface($c) => $body,
1816 }
1817 };
1818}
1819
1820impl Curve3d for Curve {
1821 fn domain(&self) -> (f64, f64) {
1822 dispatch!(self, c => c.domain())
1823 }
1824
1825 fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1826 dispatch!(self, c => c.point_at(u, tol))
1827 }
1828
1829 fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1830 dispatch!(self, c => c.d1_at(u, tol))
1831 }
1832
1833 fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1834 dispatch!(self, c => c.derivatives_at(u, n, tol))
1835 }
1836
1837 fn kind(&self) -> CurveKind {
1838 dispatch!(self, c => c.kind())
1839 }
1840
1841 fn continuity(&self) -> Continuity {
1842 dispatch!(self, c => c.continuity())
1843 }
1844
1845 fn is_closed(&self, tol: Tolerances) -> bool {
1846 dispatch!(self, c => c.is_closed(tol))
1847 }
1848
1849 fn is_periodic(&self) -> bool {
1850 dispatch!(self, c => c.is_periodic())
1851 }
1852}
1853
1854impl Transformable for Curve {
1855 fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
1856 if t.kind() == ogeom_math::TransformKind::Identity {
1857 return Ok(self.clone());
1858 }
1859 Ok(match self {
1860 Self::Line(c) => Self::Line(LineCurve {
1861 axis: Axis::new(
1862 t.apply(c.axis.location),
1863 t.apply_direction(c.axis.direction, tol)?,
1864 ),
1865 domain: (
1868 c.domain.0 * t.scale_factor().abs(),
1869 c.domain.1 * t.scale_factor().abs(),
1870 ),
1871 }),
1872 Self::Circle(c) => Self::Circle(CircleCurve {
1877 circle: c.circle.transformed(t, tol)?,
1878 ..*c
1879 }),
1880 Self::Ellipse(c) => Self::Ellipse(EllipseCurve {
1881 ellipse: c.ellipse.transformed(t, tol)?,
1882 ..*c
1883 }),
1884 Self::Hyperbola(c) => Self::Hyperbola(HyperbolaCurve {
1885 hyperbola: c.hyperbola.transformed(t, tol)?,
1886 ..*c
1887 }),
1888 Self::Parabola(c) => Self::Parabola(ParabolaCurve {
1891 parabola: c.parabola.transformed(t, tol)?,
1892 domain: (
1893 c.domain.0 * t.scale_factor().abs(),
1894 c.domain.1 * t.scale_factor().abs(),
1895 ),
1896 ..*c
1897 }),
1898 Self::BSpline(c) => {
1899 let control = c
1900 .control
1901 .iter()
1902 .map(|w| Weighted::new(t.apply(w.point()), w.weight, tol))
1903 .collect::<OgeomResult<Vec<_>>>()?;
1904 Self::BSpline(BSplineCurve {
1905 control,
1906 knots: c.knots.clone(),
1907 rational: c.rational,
1908 periodic: c.periodic,
1909 })
1910 }
1911 Self::Helix(c) => Self::Helix(HelixCurve {
1912 frame: t.apply_frame(&c.frame, tol)?,
1913 radius: c.radius * t.scale_factor().abs(),
1914 pitch: c.pitch * t.scale_factor().abs(),
1915 taper: c.taper * t.scale_factor().abs(),
1916 ..*c
1917 }),
1918 Self::Offset(c) => Self::Offset(Box::new(OffsetCurve {
1921 basis: c.basis.transformed(t, tol)?,
1922 distance: c.distance
1923 * t.scale_factor().abs()
1924 * if t.preserves_handedness() { 1.0 } else { -1.0 },
1925 reference: t.apply_direction(c.reference, tol)?,
1926 })),
1927 Self::OnSurface(c) => {
1931 use crate::traits::Surface as _;
1932 let surface = c.surface.transformed(t, tol)?;
1933 let (from, to) = (c.surface.domain(), surface.domain());
1934 let ratio = |a: (f64, f64), b: (f64, f64)| {
1935 let span = a.1 - a.0;
1936 if span.is_finite() && span != 0.0 && (b.1 - b.0).is_finite() {
1937 (b.1 - b.0) / span
1938 } else {
1939 1.0
1940 }
1941 };
1942 Self::OnSurface(Box::new(CurveOnSurface {
1943 pcurve: c
1944 .pcurve
1945 .stretched(ratio(from.0, to.0), ratio(from.1, to.1), tol)?,
1946 surface,
1947 }))
1948 }
1949 Self::Trimmed(c) => {
1950 let basis = c.basis.transformed(t, tol)?;
1951 let (from, to) = (c.basis.domain(), basis.domain());
1956 Self::Trimmed(Box::new(TrimmedCurve {
1957 domain: (
1958 crate::surface::carried(c.domain.0, from, to),
1959 crate::surface::carried(c.domain.1, from, to),
1960 ),
1961 basis,
1962 reversed: c.reversed,
1963 }))
1964 }
1965 })
1966 }
1967}
1968
1969impl Reversible for Curve {
1970 fn reversed(&self) -> Self {
1971 match self {
1972 Self::Line(c) => Self::Line(LineCurve {
1973 axis: Axis::new(
1974 c.axis.point_at(c.domain.0 + c.domain.1),
1975 c.axis.direction.reversed(),
1976 ),
1977 domain: c.domain,
1978 }),
1979 Self::Circle(c) => Self::Circle(CircleCurve {
1980 reversed: !c.reversed,
1981 ..*c
1982 }),
1983 Self::Ellipse(c) => Self::Ellipse(EllipseCurve {
1984 reversed: !c.reversed,
1985 ..*c
1986 }),
1987 Self::Hyperbola(c) => Self::Hyperbola(HyperbolaCurve {
1988 reversed: !c.reversed,
1989 ..*c
1990 }),
1991 Self::Parabola(c) => Self::Parabola(ParabolaCurve {
1992 reversed: !c.reversed,
1993 ..*c
1994 }),
1995 Self::BSpline(c) => {
1996 let (knots, control) = bspline::reverse(&c.knots, &c.control);
1997 Self::BSpline(BSplineCurve {
1998 knots,
1999 control,
2000 rational: c.rational,
2001 periodic: c.periodic,
2002 })
2003 }
2004 Self::Helix(c) => Self::Helix(HelixCurve {
2005 reversed: !c.reversed,
2006 ..*c
2007 }),
2008 Self::Offset(c) => Self::Offset(Box::new(OffsetCurve {
2011 basis: c.basis.reversed(),
2012 distance: -c.distance,
2013 reference: c.reference,
2014 })),
2015 Self::OnSurface(c) => Self::OnSurface(Box::new(CurveOnSurface {
2016 pcurve: c.pcurve.reversed(),
2017 surface: c.surface.clone(),
2018 })),
2019 Self::Trimmed(c) => Self::Trimmed(Box::new(TrimmedCurve {
2023 reversed: !c.reversed,
2024 ..(**c).clone()
2025 })),
2026 }
2027 }
2028}
2029
2030impl From<LineCurve> for Curve {
2031 fn from(c: LineCurve) -> Self {
2032 Self::Line(c)
2033 }
2034}
2035impl From<CircleCurve> for Curve {
2036 fn from(c: CircleCurve) -> Self {
2037 Self::Circle(c)
2038 }
2039}
2040impl From<EllipseCurve> for Curve {
2041 fn from(c: EllipseCurve) -> Self {
2042 Self::Ellipse(c)
2043 }
2044}
2045impl From<HelixCurve> for Curve {
2046 fn from(c: HelixCurve) -> Self {
2047 Self::Helix(c)
2048 }
2049}
2050
2051impl From<HyperbolaCurve> for Curve {
2052 fn from(c: HyperbolaCurve) -> Self {
2053 Self::Hyperbola(c)
2054 }
2055}
2056impl From<ParabolaCurve> for Curve {
2057 fn from(c: ParabolaCurve) -> Self {
2058 Self::Parabola(c)
2059 }
2060}
2061impl From<BSplineCurve> for Curve {
2062 fn from(c: BSplineCurve) -> Self {
2063 Self::BSpline(c)
2064 }
2065}
2066impl From<TrimmedCurve> for Curve {
2067 fn from(c: TrimmedCurve) -> Self {
2068 Self::Trimmed(Box::new(c))
2069 }
2070}
2071
2072#[cfg(test)]
2073#[allow(clippy::unwrap_used)]
2074mod reseam_tests {
2075 use super::*;
2076 use ogeom_core::Tolerances;
2077 use ogeom_math::{KnotVector, Point};
2078
2079 #[test]
2080 fn a_reseamed_closed_curve_is_the_same_curve_from_a_new_start() {
2081 let tol = Tolerances::millimetres();
2082 let ring = [
2084 Point::new(1.0, 0.0, 0.0),
2085 Point::new(1.0, 1.0, 0.5),
2086 Point::new(-1.0, 1.0, 0.0),
2087 Point::new(-1.0, -1.0, -0.5),
2088 Point::new(1.0, -1.0, 0.0),
2089 Point::new(1.0, 0.0, 0.0),
2090 ];
2091 let knots = KnotVector::clamped_uniform(3, ring.len()).unwrap();
2092 let curve = BSplineCurve::new(knots, ring.to_vec(), tol).unwrap();
2093 let (start, end) = curve.domain();
2094 let seam = 0.35;
2095 let moved = curve.reseamed_at(seam, tol).unwrap();
2096 let (new_start, new_end) = moved.domain();
2097 assert!((new_start - seam).abs() < 1e-12, "begins at the new seam");
2098 assert!(
2099 ((new_end - new_start) - (end - start)).abs() < 1e-12,
2100 "keeps its length"
2101 );
2102 for i in 0..=40 {
2103 let s = (end - start) * f64::from(i) / 40.0;
2104 let old_u = if seam + s <= end {
2105 seam + s
2106 } else {
2107 seam + s - (end - start)
2108 };
2109 let a = curve.point_at(old_u, tol).unwrap();
2110 let b = moved.point_at(new_start + s, tol).unwrap();
2111 assert!(a.is_equal(b, tol), "at {s} along: {a:?} against {b:?}");
2112 }
2113 }
2114}
2115
2116#[cfg(test)]
2117#[allow(clippy::unwrap_used)]
2118mod tests {
2119 use super::*;
2120 use approx::assert_relative_eq;
2121 use ogeom_math::{Direction, Frame};
2122
2123 const T: Tolerances = Tolerances::millimetres();
2124
2125 #[test]
2128 fn a_reversed_conic_spelled_forward_is_the_same_curve() {
2129 let frame = Frame::new(Point::new(1.0, 2.0, 3.0), Direction::Y, Direction::Z, T).unwrap();
2130 let curves = [
2131 Curve::Circle(CircleCurve::new(Circle::new(frame, 2.0, T).unwrap())),
2132 Curve::Ellipse(EllipseCurve::new(Ellipse::new(frame, 5.0, 3.0, T).unwrap())),
2133 ];
2134 for reversed in curves.map(|c| c.reversed()) {
2135 let (forward, turned) = match &reversed {
2136 Curve::Circle(c) => {
2137 let f = c.forward(T).unwrap();
2138 (f.is_reversed(), Curve::Circle(f))
2139 }
2140 Curve::Ellipse(e) => {
2141 let f = e.forward(T).unwrap();
2142 (f.is_reversed(), Curve::Ellipse(f))
2143 }
2144 _ => unreachable!(),
2145 };
2146 assert!(!forward);
2147 for i in 0..8 {
2148 let u = 0.3 + f64::from(i) * 0.8;
2149 let (a, b) = (
2150 reversed.point_at(u, T).unwrap(),
2151 turned.point_at(u, T).unwrap(),
2152 );
2153 assert!(a.distance(b) < 1e-12, "{u}: {a:?} {b:?}");
2154 }
2155 }
2156 }
2157
2158 #[test]
2161 fn a_periodic_spline_is_closed() {
2162 let ring = [
2163 Point::new(0.0, 0.0, 0.0),
2164 Point::new(10.0, 0.0, 1.0),
2165 Point::new(10.0, 10.0, 2.0),
2166 Point::new(0.0, 10.0, 0.0),
2167 ];
2168 let c: Curve = BSplineCurve::periodic(&ring, 3, T).unwrap().into();
2169 assert!(c.is_periodic() && c.is_closed(T));
2170 let sweep: crate::surface::SurfaceGeometry =
2171 crate::surface::ExtrusionSurface::new(c, Direction::Z, 10.0)
2172 .unwrap()
2173 .into();
2174 assert!(crate::traits::Surface::is_closed_u(&sweep, T));
2175 }
2176
2177 #[test]
2180 fn a_conical_helix_measures_its_length() {
2181 for (taper, pitch) in [(0.0, 2.0), (3.0, 2.0), (-1.5, 0.5), (4.0, 0.1)] {
2182 let helix = HelixCurve::conical(Frame::WORLD, 10.0, pitch, taper, 0.0, 12.0).unwrap();
2183 let steps = 200_000;
2184 let mut chords = 0.0;
2185 let mut last = helix.point_at(0.0, T).unwrap();
2186 for k in 1..=steps {
2187 let next = helix
2188 .point_at(12.0 * f64::from(k) / f64::from(steps), T)
2189 .unwrap();
2190 chords += next.distance(last);
2191 last = next;
2192 }
2193 let exact = helix.arc_length(0.0, 12.0);
2194 assert!(
2195 (exact - chords).abs() < 1e-6 * exact,
2196 "{taper} {pitch}: {exact} against {chords}"
2197 );
2198 }
2199 }
2200
2201 #[test]
2207 fn a_scaled_curve_is_the_image_of_the_original() {
2208 use crate::curve2d::{Line2d, Offset2d, PlanarCurve, Trimmed2d};
2209 use crate::surface::{CylinderSurface, PlaneSurface, SurfaceGeometry};
2210 use crate::traits::Curve2d as _;
2211 use ogeom_math::{Cylinder, Parabola, Plane, Point2, Transform, Transform2};
2212 let scaling = Transform::scaling(Point::ORIGIN, 2.0, T).unwrap();
2213 let parabola: Curve =
2214 ParabolaCurve::new(Parabola::new(Frame::WORLD, 5.0, T).unwrap(), 10.0)
2215 .unwrap()
2216 .into();
2217 let parabola: Curve = TrimmedCurve::new(parabola, -10.0, 10.0, T).unwrap().into();
2218 let segment: PlanarCurve =
2219 Line2d::segment(Point2::new(10.0, 0.0), Point2::new(10.0, 20.0), T)
2220 .unwrap()
2221 .into();
2222 let on_plane = Curve::OnSurface(Box::new(CurveOnSurface::new(
2223 segment.clone(),
2224 PlaneSurface::over(Plane::XY, (-100.0, 100.0), (-100.0, 100.0))
2225 .unwrap()
2226 .into(),
2227 )));
2228 let slant: PlanarCurve = Line2d::segment(Point2::new(0.0, 1.0), Point2::new(3.0, 4.0), T)
2229 .unwrap()
2230 .into();
2231 let drum: SurfaceGeometry =
2232 CylinderSurface::new(Cylinder::new(Frame::WORLD, 5.0, T).unwrap(), (-10.0, 10.0))
2233 .unwrap()
2234 .into();
2235 let on_drum = Curve::OnSurface(Box::new(CurveOnSurface::new(slant, drum)));
2236 for curve in [parabola, on_plane, on_drum] {
2237 let moved = curve.transformed(&scaling, T).unwrap();
2238 let (a, b) = curve.domain();
2239 let (c, d) = moved.domain();
2240 for k in 0..=8 {
2241 let f = f64::from(k) / 8.0;
2242 let want = scaling.apply(curve.point_at(a + (b - a) * f, T).unwrap());
2243 let got = moved.point_at(c + (d - c) * f, T).unwrap();
2244 assert!(got.distance(want) < 1e-9, "{got:?} against {want:?}");
2245 }
2246 }
2247 let flat = Transform2::scaling(Point2::ORIGIN, 2.0, T).unwrap();
2248 let line: PlanarCurve = Line2d::segment(Point2::new(0.0, 0.0), Point2::new(10.0, 0.0), T)
2249 .unwrap()
2250 .into();
2251 let nested: PlanarCurve = Trimmed2d::new(
2252 Trimmed2d::new(line.clone(), 1.0, 9.0, T).unwrap().into(),
2253 3.0,
2254 7.0,
2255 T,
2256 )
2257 .unwrap()
2258 .into();
2259 let beside: PlanarCurve = Trimmed2d::new(
2260 PlanarCurve::Offset(Box::new(Offset2d::new(line, 1.0).unwrap())),
2261 3.0,
2262 7.0,
2263 T,
2264 )
2265 .unwrap()
2266 .into();
2267 for curve in [nested, beside] {
2268 let moved = curve.transformed(&flat, T).unwrap();
2269 for (at, on) in [
2270 (curve.domain().0, moved.domain().0),
2271 (curve.domain().1, moved.domain().1),
2272 ] {
2273 let want = flat.apply(curve.point_at(at, T).unwrap());
2274 assert!(moved.point_at(on, T).unwrap().distance(want) < 1e-9);
2275 }
2276 }
2277 }
2278
2279 #[test]
2282 fn a_mirrored_offset_curve_is_the_image_of_the_original() {
2283 use ogeom_math::{Circle, Transform};
2284 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 20.0, T).unwrap()).into();
2285 let offset: Curve = Curve::Offset(Box::new(
2286 OffsetCurve::new(circle, 3.0, Direction::Z).unwrap(),
2287 ));
2288 for t in [
2289 Transform::plane_mirror(Point::ORIGIN, Direction::X),
2290 Transform::point_mirror(Point::new(1.0, 2.0, 3.0)),
2291 Transform::scaling(Point::ORIGIN, -2.0, T).unwrap(),
2292 ] {
2293 let moved = offset.transformed(&t, T).unwrap();
2294 for u in [0.0, 1.0, 2.5, 4.0] {
2295 let want = t.apply(offset.point_at(u, T).unwrap());
2296 let got = moved.point_at(u, T).unwrap();
2297 assert!(
2298 got.distance(want) < 1e-9,
2299 "{t:?} at {u}: {got:?} against {want:?}"
2300 );
2301 }
2302 }
2303 }
2304
2305 fn tilted() -> Frame {
2306 Frame::new(
2307 Point::new(1.0, -2.0, 3.0),
2308 Direction::from_coords(1.0, 2.0, 3.0, T).unwrap(),
2309 Direction::X,
2310 T,
2311 )
2312 .unwrap()
2313 }
2314
2315 fn every_curve() -> Vec<Curve> {
2316 let spline = {
2317 let control = vec![
2318 Point::new(0.0, 0.0, 0.0),
2319 Point::new(1.0, 2.0, 0.0),
2320 Point::new(3.0, 1.0, 1.0),
2321 Point::new(5.0, 0.0, 2.0),
2322 Point::new(6.0, -1.0, 0.0),
2323 ];
2324 let knots = KnotVector::clamped_uniform(3, control.len()).unwrap();
2325 BSplineCurve::new(knots, control, T).unwrap()
2326 };
2327 vec![
2328 LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
2329 .unwrap()
2330 .into(),
2331 CircleCurve::new(Circle::new(tilted(), 2.0, T).unwrap()).into(),
2332 EllipseCurve::new(Ellipse::new(tilted(), 5.0, 3.0, T).unwrap()).into(),
2333 HyperbolaCurve::new(Hyperbola::new(tilted(), 3.0, 4.0, T).unwrap(), 1.5)
2334 .unwrap()
2335 .into(),
2336 ParabolaCurve::new(Parabola::new(tilted(), 2.0, T).unwrap(), 4.0)
2337 .unwrap()
2338 .into(),
2339 spline.clone().into(),
2340 HelixCurve::new(tilted(), 2.5, 1.25, 2.0).unwrap().into(),
2341 TrimmedCurve::new(spline.into(), 0.2, 0.8, T)
2342 .unwrap()
2343 .into(),
2344 ]
2345 }
2346
2347 #[test]
2348 fn a_helix_rises_one_pitch_per_turn_and_knows_its_length() {
2349 let helix = HelixCurve::new(Frame::WORLD, 3.0, 2.0, 2.0).unwrap();
2350 let tau = core::f64::consts::TAU;
2351 let start = helix.point_at(0.0, T).unwrap();
2352 let after_one_turn = helix.point_at(tau, T).unwrap();
2353 assert_relative_eq!(start.x, 3.0);
2354 assert_relative_eq!(after_one_turn.x, 3.0, epsilon = 1e-12);
2355 assert_relative_eq!(after_one_turn.y, 0.0, epsilon = 1e-12);
2356 assert_relative_eq!(after_one_turn.z - start.z, 2.0, epsilon = 1e-12);
2357
2358 let exact = helix.arc_length(0.0, 2.0 * tau);
2361 assert_relative_eq!(exact, 2.0 * tau * 3.0f64.hypot(2.0 / tau), epsilon = 1e-12);
2362 let mut chords = 0.0;
2363 let n = 20_000;
2364 for i in 0..n {
2365 let a = 2.0 * tau * f64::from(i) / f64::from(n);
2366 let b = 2.0 * tau * f64::from(i + 1) / f64::from(n);
2367 chords += helix
2368 .point_at(a, T)
2369 .unwrap()
2370 .distance(helix.point_at(b, T).unwrap());
2371 }
2372 assert!((exact - chords) / exact < 1e-6, "{exact} vs {chords}");
2373
2374 let left = HelixCurve::new(Frame::WORLD, 3.0, -2.0, 2.0).unwrap();
2378 let q = left.point_at(tau / 4.0, T).unwrap();
2379 assert_relative_eq!(q.y, 3.0, epsilon = 1e-12);
2380 assert!(q.z < 0.0);
2381 }
2382
2383 #[test]
2384 fn a_reversed_helix_swaps_its_ends_and_flips_its_tangent() {
2385 let helix: Curve = HelixCurve::new(tilted(), 2.0, 1.0, 1.5).unwrap().into();
2386 let (lo, hi) = helix.domain();
2387 let back = helix.reversed();
2388 assert_relative_eq!(
2389 helix
2390 .point_at(lo, T)
2391 .unwrap()
2392 .distance(back.point_at(hi, T).unwrap()),
2393 0.0,
2394 epsilon = 1e-12
2395 );
2396 let d_fwd = helix.d1_at(f64::midpoint(lo, hi), T).unwrap();
2397 let d_back = back.d1_at(f64::midpoint(lo, hi), T).unwrap();
2398 assert_relative_eq!((d_fwd + d_back).magnitude(), 0.0, epsilon = 1e-12);
2399 }
2400
2401 #[test]
2402 fn an_offset_circle_is_the_larger_circle() {
2403 let circle: Curve = CircleCurve::new(Circle::new(tilted(), 2.0, T).unwrap()).into();
2407 let bigger = Circle::new(tilted(), 3.0, T).unwrap();
2408 let offset = OffsetCurve::new(circle, 1.0, tilted().z()).unwrap();
2409 for i in 0..8 {
2410 let t = core::f64::consts::TAU * f64::from(i) / 8.0;
2411 let p = offset.point_at(t, T).unwrap();
2412 assert_relative_eq!(p.distance(bigger.centre()), 3.0, epsilon = 1e-12);
2413 }
2414 let h = 1e-6;
2416 let d = offset.d1_at(1.0, T).unwrap();
2417 let fd = (offset.point_at(1.0 + h, T).unwrap() - offset.point_at(1.0 - h, T).unwrap())
2418 / (2.0 * h);
2419 assert_relative_eq!((d - fd).magnitude(), 0.0, epsilon = 1e-5);
2420 assert!(offset.derivatives_at(1.0, 2, T).is_err());
2422 }
2423
2424 #[test]
2425 fn a_sloped_line_on_a_cylinder_chart_is_a_helix() {
2426 use crate::curve2d::{Line2d, PlanarCurve};
2427 use crate::surface::{CylinderSurface, SurfaceGeometry};
2428 use ogeom_math::{Cylinder, Point2};
2429
2430 let radius = 3.0;
2434 let pitch = 2.0;
2435 let tau = core::f64::consts::TAU;
2436 let cylinder = SurfaceGeometry::Cylinder(
2437 CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-1.0, 5.0))
2438 .unwrap(),
2439 );
2440 let rise = pitch / tau;
2441 let slope = (1.0 + rise * rise).sqrt();
2442 let line = Line2d::segment(Point2::new(0.0, 0.0), Point2::new(tau, pitch), T).unwrap();
2443 let on_surface = CurveOnSurface::new(PlanarCurve::Line(line), cylinder);
2444 let helix = HelixCurve::new(Frame::WORLD, radius, pitch, 1.0).unwrap();
2445 for i in 0..=8 {
2446 let angle = tau * f64::from(i) / 8.0;
2447 let lifted = on_surface.point_at(angle * slope, T).unwrap();
2449 let wound = helix.point_at(angle, T).unwrap();
2450 assert_relative_eq!(lifted.distance(wound), 0.0, epsilon = 1e-9);
2451 }
2452 let h = 1e-6;
2455 let d2 = on_surface.derivatives_at(2.0, 2, T).unwrap()[2];
2456 let fd = (on_surface.d1_at(2.0 + h, T).unwrap() - on_surface.d1_at(2.0 - h, T).unwrap())
2457 / (2.0 * h);
2458 assert_relative_eq!((d2 - fd).magnitude(), 0.0, epsilon = 1e-5);
2459 }
2460
2461 #[test]
2462 fn a_helix_has_no_exact_spline_and_says_so() {
2463 let helix: Curve = HelixCurve::new(Frame::WORLD, 1.0, 1.0, 1.0).unwrap().into();
2464 assert!(helix.to_bspline(T).is_err());
2465 }
2466
2467 fn interior(c: &Curve, n: usize) -> Vec<f64> {
2469 let (a, b) = c.domain();
2470 (1..n)
2471 .map(|i| {
2472 #[allow(clippy::cast_precision_loss)]
2473 let t = i as f64 / n as f64;
2474 a + (b - a) * t
2475 })
2476 .collect()
2477 }
2478
2479 #[test]
2480 fn every_order_agrees_with_the_difference_of_the_one_below() {
2481 let h = 1e-5;
2482 let mut curves = every_curve();
2483 curves.push(
2484 HelixCurve::conical(tilted(), 4.0, 1.5, 2.0, 0.0, 6.0)
2485 .unwrap()
2486 .into(),
2487 );
2488 let reversed: Vec<Curve> = curves.iter().map(Reversible::reversed).collect();
2489 curves.extend(reversed);
2490 curves.retain(|c| !matches!(c.kind(), CurveKind::BSpline | CurveKind::Trimmed));
2492 for c in curves {
2493 for u in interior(&c, 6) {
2494 let at = c.derivatives_at(u, 5, T).unwrap();
2495 let below = c.derivatives_at(u + h, 4, T).unwrap();
2496 let above = c.derivatives_at(u - h, 4, T).unwrap();
2497 for order in 2..=5 {
2498 let numeric = (below[order - 1] - above[order - 1]) * (1.0 / (2.0 * h));
2499 let scale = numeric.magnitude().max(1.0);
2500 assert!(
2501 (at[order] - numeric).magnitude() <= 1e-5 * scale,
2502 "{:?} order {order} at {u}: {:?} against {numeric:?}",
2503 c.kind(),
2504 at[order]
2505 );
2506 }
2507 }
2508 }
2509 }
2510
2511 #[test]
2512 fn every_curves_derivative_agrees_with_finite_differences() {
2513 let h = 1e-6;
2514 for c in every_curve() {
2515 for u in interior(&c, 8) {
2516 let d1 = c.d1_at(u, T).unwrap();
2517 let numeric = (c.point_at(u + h, T).unwrap() - c.point_at(u - h, T).unwrap())
2518 * (1.0 / (2.0 * h));
2519 let scale = numeric.magnitude().max(1.0);
2520 assert!(
2521 (d1 - numeric).magnitude() <= 1e-5 * scale,
2522 "{:?} at {u}: {d1:?} vs {numeric:?}",
2523 c.kind()
2524 );
2525 }
2526 }
2527 }
2528
2529 #[test]
2530 fn derivatives_at_zero_returns_the_point_itself() {
2531 for c in every_curve() {
2532 for u in interior(&c, 4) {
2533 let d = c.derivatives_at(u, 0, T).unwrap();
2534 assert_eq!(d.len(), 1);
2535 assert!(Point::from_vector(d[0]).is_equal(c.point_at(u, T).unwrap(), T));
2536 }
2537 }
2538 }
2539
2540 #[test]
2541 fn out_of_domain_parameters_are_refused_for_non_periodic_curves() {
2542 for c in every_curve() {
2543 let (a, b) = c.domain();
2544 if c.is_periodic() {
2545 assert!(c.point_at(b + 1.0, T).is_ok());
2547 assert!(c.point_at(a - 1.0, T).is_ok());
2548 } else {
2549 assert!(c.point_at(b + 1.0, T).is_err(), "{:?}", c.kind());
2550 assert!(c.point_at(a - 1.0, T).is_err(), "{:?}", c.kind());
2551 }
2552 }
2553 }
2554
2555 #[test]
2556 fn a_periodic_curve_wraps_to_the_same_point() {
2557 let c: Curve = CircleCurve::new(Circle::new(tilted(), 2.0, T).unwrap()).into();
2558 assert!(c.is_periodic() && c.is_closed(T));
2559 let base = c.point_at(0.7, T).unwrap();
2560 for k in [-2.0_f64, -1.0, 1.0, 3.0] {
2561 let wrapped = c
2562 .point_at(k.mul_add(core::f64::consts::TAU, 0.7), T)
2563 .unwrap();
2564 assert!(base.is_equal(wrapped, T), "wrap by {k} moved the point");
2565 }
2566 }
2567
2568 #[test]
2569 fn reversal_traverses_the_same_points_backwards() {
2570 for c in every_curve() {
2571 let r = c.reversed();
2572 let (a, b) = c.domain();
2573 assert_eq!(r.domain(), (a, b), "{:?} changed its domain", c.kind());
2574 for i in 0..=8 {
2575 let t = f64::from(i) / 8.0;
2576 let forward = c.point_at(a + (b - a) * t, T).unwrap();
2577 let backward = r.point_at(a + (b - a) * (1.0 - t), T).unwrap();
2578 assert!(
2579 forward.is_equal(backward, T),
2580 "{:?} at t = {t}: {forward:?} vs {backward:?}",
2581 c.kind()
2582 );
2583 }
2584 }
2585 }
2586
2587 #[test]
2588 fn reversing_twice_is_the_identity() {
2589 for c in every_curve() {
2590 let twice = c.reversed().reversed();
2591 for u in interior(&c, 8) {
2592 assert!(
2593 c.point_at(u, T)
2594 .unwrap()
2595 .is_equal(twice.point_at(u, T).unwrap(), T),
2596 "{:?}",
2597 c.kind()
2598 );
2599 }
2600 }
2601 }
2602
2603 #[test]
2604 fn a_reversed_curves_tangent_points_the_other_way() {
2605 for c in every_curve() {
2606 let r = c.reversed();
2607 let (a, b) = c.domain();
2608 let u = a + (b - a) * 0.4;
2609 let forward = c.tangent_at(u, T).unwrap();
2610 let backward = r.tangent_at(mirror(u, a, b), T).unwrap();
2611 assert!(forward.is_opposite(backward, T), "{:?}", c.kind());
2612 }
2613 }
2614
2615 #[test]
2616 fn transforms_move_curves_and_preserve_their_shape() {
2617 let t =
2618 Transform::rotation(Axis::X, 0.7) * Transform::translation(Vector::new(1.0, 2.0, 3.0));
2619 for c in every_curve() {
2620 let moved = c.transformed(&t, T).unwrap();
2621 assert_eq!(moved.kind(), c.kind());
2622 for u in interior(&c, 8) {
2623 let expected = t.apply(c.point_at(u, T).unwrap());
2624 assert!(
2625 moved.point_at(u, T).unwrap().is_equal(expected, T),
2626 "{:?} at {u}",
2627 c.kind()
2628 );
2629 }
2630 }
2631 }
2632
2633 #[test]
2634 fn a_scaling_rescales_a_lines_arc_length_domain() {
2635 let line = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T).unwrap();
2638 let c: Curve = line.into();
2639 assert_eq!(c.domain(), (0.0, 5.0));
2640 let scaled = c
2641 .transformed(&Transform::scaling(Point::ORIGIN, 2.0, T).unwrap(), T)
2642 .unwrap();
2643 assert_eq!(scaled.domain(), (0.0, 10.0));
2644 assert!(
2645 scaled
2646 .end(T)
2647 .unwrap()
2648 .is_equal(Point::new(6.0, 8.0, 0.0), T)
2649 );
2650 }
2651
2652 #[test]
2653 fn mirroring_a_circle_moves_every_point_by_the_mirror() {
2654 let c: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2658 let m = Transform::plane_mirror(Point::ORIGIN, Direction::X);
2659 let mirrored = c.transformed(&m, T).unwrap();
2660 for u in interior(&c, 8) {
2661 let expected = m.apply(c.point_at(u, T).unwrap());
2662 assert!(
2663 mirrored.point_at(u, T).unwrap().is_equal(expected, T),
2664 "at {u}"
2665 );
2666 }
2667 }
2668
2669 #[test]
2670 fn a_line_segments_parameter_is_arc_length() {
2671 let line = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T).unwrap();
2672 assert_eq!(line.domain(), (0.0, 5.0));
2673 assert!(line.point_at(0.0, T).unwrap().is_equal(Point::ORIGIN, T));
2674 assert!(
2675 line.point_at(5.0, T)
2676 .unwrap()
2677 .is_equal(Point::new(3.0, 4.0, 0.0), T)
2678 );
2679 assert!(
2680 line.point_at(2.5, T)
2681 .unwrap()
2682 .is_equal(Point::new(1.5, 2.0, 0.0), T)
2683 );
2684 assert_relative_eq!(
2685 line.d1_at(1.0, T).unwrap().magnitude(),
2686 1.0,
2687 epsilon = 1e-15
2688 );
2689 }
2690
2691 #[test]
2692 fn degenerate_constructions_are_refused() {
2693 assert!(LineCurve::segment(Point::ORIGIN, Point::ORIGIN, T).is_err());
2694 assert!(LineCurve::over(Axis::X, 1.0, 1.0).is_err());
2695 assert!(LineCurve::over(Axis::X, 0.0, f64::NAN).is_err());
2696 assert!(HyperbolaCurve::new(Hyperbola::new(tilted(), 1.0, 1.0, T).unwrap(), 0.0).is_err());
2697 assert!(ParabolaCurve::new(Parabola::new(tilted(), 1.0, T).unwrap(), -1.0).is_err());
2698 }
2699
2700 #[test]
2701 fn trimming_is_bounds_checked() {
2702 let base: Curve = LineCurve::over(Axis::X, 0.0, 10.0).unwrap().into();
2703 assert!(TrimmedCurve::new(base.clone(), 2.0, 8.0, T).is_ok());
2704 assert!(
2705 TrimmedCurve::new(base.clone(), 8.0, 2.0, T).is_err(),
2706 "empty"
2707 );
2708 assert!(
2709 TrimmedCurve::new(base.clone(), 5.0, 5.0, T).is_err(),
2710 "empty"
2711 );
2712 assert!(
2713 TrimmedCurve::new(base, -1.0, 5.0, T).is_err(),
2714 "outside the basis"
2715 );
2716 }
2717
2718 #[test]
2719 fn a_trimmed_curve_agrees_with_its_basis() {
2720 let base: Curve = CircleCurve::new(Circle::new(tilted(), 2.0, T).unwrap()).into();
2721 let trimmed = TrimmedCurve::new(base.clone(), 0.5, 2.0, T).unwrap();
2722 assert_eq!(trimmed.domain(), (0.5, 2.0));
2723 for i in 0..=8 {
2724 let u = 0.5 + 1.5 * (f64::from(i) / 8.0);
2725 assert!(
2726 trimmed
2727 .point_at(u, T)
2728 .unwrap()
2729 .is_equal(base.point_at(u, T).unwrap(), T)
2730 );
2731 }
2732 assert!(trimmed.point_at(0.4, T).is_err());
2733 assert!(trimmed.point_at(2.1, T).is_err());
2734 }
2735
2736 #[test]
2737 fn a_rational_curve_is_recognized_and_a_uniformly_weighted_one_is_not() {
2738 let knots = KnotVector::clamped_uniform(2, 3).unwrap();
2739 let points = [
2740 Point::new(1.0, 0.0, 0.0),
2741 Point::new(1.0, 1.0, 0.0),
2742 Point::new(0.0, 1.0, 0.0),
2743 ];
2744
2745 let uniform: Vec<_> = points
2746 .iter()
2747 .map(|p| Weighted::new(*p, 3.0, T).unwrap())
2748 .collect();
2749 assert!(
2750 !BSplineCurve::rational(knots.clone(), uniform)
2751 .unwrap()
2752 .is_rational(),
2753 "equal weights are polynomial whatever their value"
2754 );
2755
2756 let w = core::f64::consts::FRAC_1_SQRT_2;
2757 let arc: Vec<_> = points
2758 .iter()
2759 .zip([1.0, w, 1.0])
2760 .map(|(p, w)| Weighted::new(*p, w, T).unwrap())
2761 .collect();
2762 let c = BSplineCurve::rational(knots, arc).unwrap();
2763 assert!(c.is_rational());
2764 for i in 0..=20 {
2766 let u = f64::from(i) / 20.0;
2767 assert_relative_eq!(
2768 c.point_at(u, T).unwrap().to_vector().magnitude(),
2769 1.0,
2770 epsilon = 1e-14
2771 );
2772 }
2773 }
2774
2775 #[test]
2776 fn spline_continuity_follows_interior_knot_multiplicity() {
2777 let control = vec![
2778 Point::ORIGIN,
2779 Point::new(1.0, 1.0, 0.0),
2780 Point::new(2.0, 0.0, 0.0),
2781 Point::new(3.0, 1.0, 0.0),
2782 Point::new(4.0, 0.0, 0.0),
2783 ];
2784 let smooth = BSplineCurve::new(
2787 KnotVector::clamped_uniform(3, control.len()).unwrap(),
2788 control.clone(),
2789 T,
2790 )
2791 .unwrap();
2792 assert_eq!(smooth.continuity(), Continuity::C2);
2793
2794 let bezier = BSplineCurve::new(
2797 KnotVector::clamped_uniform(4, control.len()).unwrap(),
2798 control.clone(),
2799 T,
2800 )
2801 .unwrap();
2802 assert_eq!(bezier.continuity(), Continuity::CInfinity);
2803
2804 let kinked = BSplineCurve::new(
2806 KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0, 1.0], 3).unwrap(),
2807 {
2808 let mut c = control.clone();
2809 c.push(Point::new(5.0, 1.0, 0.0));
2810 c
2811 },
2812 T,
2813 )
2814 .unwrap();
2815 assert_eq!(kinked.continuity(), Continuity::C1);
2816
2817 let corner = BSplineCurve::new(
2819 KnotVector::new(vec![0.0, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0], 2).unwrap(),
2820 control,
2821 T,
2822 )
2823 .unwrap();
2824 assert_eq!(corner.continuity(), Continuity::C0);
2825 }
2826
2827 #[test]
2828 fn spline_refinement_does_not_move_the_curve() {
2829 let control = vec![
2830 Point::ORIGIN,
2831 Point::new(1.0, 2.0, 0.0),
2832 Point::new(3.0, 1.0, 1.0),
2833 Point::new(5.0, 0.0, 2.0),
2834 ];
2835 let c = BSplineCurve::new(
2836 KnotVector::clamped_uniform(3, control.len()).unwrap(),
2837 control,
2838 T,
2839 )
2840 .unwrap();
2841 let refined = c.with_knot_inserted(0.5, 1, T).unwrap();
2842 let elevated = c.elevated(T).unwrap();
2843 assert_eq!(elevated.degree(), 4);
2844 for i in 0..=20 {
2845 let u = f64::from(i) / 20.0;
2846 let base = c.point_at(u, T).unwrap();
2847 assert!(refined.point_at(u, T).unwrap().is_equal(base, T));
2848 assert!(elevated.point_at(u, T).unwrap().is_equal(base, T));
2849 }
2850 }
2851
2852 #[test]
2853 fn curvature_of_a_circle_is_the_reciprocal_of_its_radius() {
2854 for r in [0.5_f64, 2.0, 50.0] {
2855 let c: Curve = CircleCurve::new(Circle::new(tilted(), r, T).unwrap()).into();
2856 assert_relative_eq!(
2857 c.curvature_at(1.1, T).unwrap(),
2858 1.0 / r,
2859 max_relative = 1e-12
2860 );
2861 }
2862 let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(1.0, 1.0, 1.0), T)
2864 .unwrap()
2865 .into();
2866 assert_relative_eq!(line.curvature_at(0.5, T).unwrap(), 0.0);
2867 }
2868
2869 #[test]
2870 fn kinds_are_reported_for_dispatch() {
2871 let kinds: Vec<_> = every_curve().iter().map(Curve3d::kind).collect();
2872 assert_eq!(
2873 kinds,
2874 vec![
2875 CurveKind::Line,
2876 CurveKind::Circle,
2877 CurveKind::Ellipse,
2878 CurveKind::Hyperbola,
2879 CurveKind::Parabola,
2880 CurveKind::BSpline,
2881 CurveKind::Helix,
2882 CurveKind::Trimmed,
2883 ]
2884 );
2885 }
2886}
2887
2888#[cfg(test)]
2889#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
2890mod conical_tests {
2891 use super::*;
2892 use ogeom_math::Vector;
2893
2894 #[test]
2898 fn a_curve_extended_to_a_point_ends_there_smoothly() {
2899 use crate::Curve3d as _;
2900 let cubic = BSplineCurve::rational(
2901 KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], 3).unwrap(),
2902 [
2903 Point::new(0.0, 0.0, 0.0),
2904 Point::new(1.0, 2.0, 0.0),
2905 Point::new(3.0, 2.5, 1.0),
2906 Point::new(4.0, 0.5, 1.0),
2907 Point::new(6.0, 1.0, 0.0),
2908 ]
2909 .iter()
2910 .map(|p| Weighted::new(*p, 1.0, T).unwrap())
2911 .collect(),
2912 )
2913 .unwrap();
2914 let circle = Circle::new(Frame::WORLD, 5.0, T).unwrap();
2915 let arc = Curve::Trimmed(Box::new(
2916 TrimmedCurve::new(
2917 Curve::Circle(CircleCurve::new(circle)),
2918 0.0,
2919 core::f64::consts::FRAC_PI_2,
2920 T,
2921 )
2922 .unwrap(),
2923 ))
2924 .to_bspline(T)
2925 .unwrap();
2926 for curve in [cubic, arc] {
2927 let (lo, hi) = curve.domain();
2928 for at_end in [true, false] {
2929 let target = if at_end {
2930 Point::new(8.0, 3.0, -1.0)
2931 } else {
2932 Point::new(-2.0, -1.0, 0.5)
2933 };
2934 let longer = curve.extended_to(at_end, target, 2, T).unwrap();
2935 let (elo, ehi) = longer.domain();
2936 let end = longer.point_at(if at_end { ehi } else { elo }, T).unwrap();
2937 assert!(end.distance(target) < 1e-9, "ends at the point: {end:?}");
2938 for i in 0..=8 {
2939 let u = lo + (hi - lo) * f64::from(i) / 8.0;
2940 let (was, now) = (
2941 curve.point_at(u, T).unwrap(),
2942 longer.point_at(u, T).unwrap(),
2943 );
2944 assert!(was.distance(now) < 1e-9, "the run itself at {u}");
2945 }
2946 let join = if at_end { hi } else { lo };
2947 let step = if at_end { 1e-6 } else { -1e-6 };
2948 let inside = curve.derivatives_at(join - step, 2, T).unwrap();
2949 let outside = longer.derivatives_at(join + step, 2, T).unwrap();
2950 for order in 1..=2 {
2951 let gap = (inside[order] - outside[order]).magnitude();
2952 let scale = inside[order].magnitude().max(1.0);
2953 assert!(gap < scale * 1e-3, "order {order} across the join: {gap}");
2954 }
2955 }
2956 }
2957 }
2958
2959 #[test]
2964 fn a_rational_arc_extended_stays_on_its_circle() {
2965 use crate::Curve3d as _;
2966 let circle = Circle::new(Frame::WORLD, 5.0, T).unwrap();
2967 let arc = TrimmedCurve::new(
2968 Curve::Circle(CircleCurve::new(circle)),
2969 0.0,
2970 core::f64::consts::FRAC_PI_2,
2971 T,
2972 )
2973 .unwrap();
2974 let spline = Curve::Trimmed(Box::new(arc)).to_bspline(T).unwrap();
2975 let (lo, hi) = spline.domain();
2976 for at_end in [true, false] {
2977 let longer = spline.extended(at_end, 4.0, 2, T).unwrap();
2978 let (elo, ehi) = longer.domain();
2979 for i in 0..=8 {
2980 let u = lo + (hi - lo) * f64::from(i) / 8.0;
2981 let (was, now) = (
2982 spline.point_at(u, T).unwrap(),
2983 longer.point_at(u, T).unwrap(),
2984 );
2985 assert!(was.distance(now) < 1e-9, "the arc itself at {u}");
2986 }
2987 let (from, to) = if at_end { (hi, ehi) } else { (elo, lo) };
2988 let mut swept = 0.0;
2989 let mut last = longer.point_at(from, T).unwrap();
2990 for i in 1..=64 {
2991 let u = from + (to - from) * f64::from(i) / 64.0;
2992 let p = longer.point_at(u, T).unwrap();
2993 assert!(
2994 (p.distance(Point::ORIGIN) - 5.0).abs() < 1e-9,
2995 "off the circle at {u}: {p:?}"
2996 );
2997 swept += last.distance(p);
2998 last = p;
2999 }
3000 assert!((swept - 4.0).abs() < 1e-3, "the length asked: {swept}");
3001 }
3002 let ring = BSplineCurve::periodic(
3003 &[
3004 Point::new(0.0, 0.0, 0.0),
3005 Point::new(1.0, 0.0, 0.0),
3006 Point::new(1.0, 1.0, 0.0),
3007 Point::new(0.0, 1.0, 0.0),
3008 ],
3009 3,
3010 T,
3011 )
3012 .unwrap();
3013 assert!(ring.extended(true, 1.0, 2, T).is_err(), "a ring has no end");
3014 }
3015
3016 const T: Tolerances = Tolerances::millimetres();
3017
3018 #[test]
3019 fn a_conical_helix_winds_its_cone_and_speaks_its_derivatives() {
3020 let tau = core::f64::consts::TAU;
3021 let helix = HelixCurve::conical(Frame::WORLD, 5.0, 3.0, 2.0, 0.0, 2.0 * tau).unwrap();
3023 for i in 0..=8 {
3024 let t = 2.0 * tau * f64::from(i) / 8.0;
3025 let p = helix.point_at(t, T).unwrap();
3026 let r = 2.0f64.mul_add(t / tau, 5.0);
3027 assert!((p.to_vector().dot(Vector::Z) - 3.0 * t / tau).abs() < 1e-9);
3028 assert!((p.x.hypot(p.y) - r).abs() < 1e-9, "radius at {t}");
3029 }
3030 let t = 1.234;
3032 let h = 1e-6;
3033 let d1 = helix.d1_at(t, T).unwrap();
3034 let fwd = helix.point_at(t + h, T).unwrap();
3035 let bwd = helix.point_at(t - h, T).unwrap();
3036 let fd = (fwd - bwd) / (2.0 * h);
3037 assert!((d1 - fd).magnitude() < 1e-6, "d1 {d1:?} against {fd:?}");
3038 }
3039
3040 #[test]
3041 fn a_helix_past_its_apex_is_refused_by_name() {
3042 let tau = core::f64::consts::TAU;
3043 let err = HelixCurve::conical(Frame::WORLD, 1.0, 3.0, -2.0, 0.0, 2.0 * tau).unwrap_err();
3044 assert!(err.to_string().contains("apex"), "{err}");
3045 }
3046
3047 #[test]
3048 fn a_periodic_bspline_wraps_smoothly_and_says_so() {
3049 let ring: Vec<Point> = (0..8)
3050 .map(|i| {
3051 let a = core::f64::consts::TAU * f64::from(i) / 8.0;
3052 Point::new(a.cos() * 4.0, a.sin() * 4.0, 0.0)
3053 })
3054 .collect();
3055 let curve = BSplineCurve::periodic(&ring, 3, T).unwrap();
3056 assert!(Curve3d::is_periodic(&curve));
3057 let (lo, hi) = Curve3d::domain(&curve);
3058 let p_lo = curve.point_at(lo, T).unwrap();
3060 let p_hi = curve.point_at(hi, T).unwrap();
3061 assert!(p_lo.distance(p_hi) < 1e-9);
3062 let d_lo = curve.d1_at(lo, T).unwrap();
3063 let d_hi = curve.d1_at(hi, T).unwrap();
3064 assert!((d_lo - d_hi).magnitude() < 1e-9, "C1 across the seam");
3065 let inside = curve.point_at(lo + 0.4, T).unwrap();
3067 let wrapped = curve.point_at(hi + 0.4, T).unwrap();
3068 assert!(inside.distance(wrapped) < 1e-9);
3069 }
3070}
3071
3072#[cfg(test)]
3073#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
3074mod widened_tests {
3075 use super::*;
3076 use ogeom_math::{Direction, Frame, Parabola};
3077
3078 const T: Tolerances = Tolerances::millimetres();
3079
3080 #[test]
3085 fn a_curve_widens_as_its_own_formula_or_not_at_all() {
3086 let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
3087 .unwrap()
3088 .into();
3089 let frame = Frame::new(Point::new(1.0, 2.0, 3.0), Direction::Z, Direction::X, T).unwrap();
3090 let parabola: Curve = ParabolaCurve::new(Parabola::new(frame, 0.7, T).unwrap(), 2.0)
3091 .unwrap()
3092 .into();
3093 for curve in [line, parabola] {
3094 let (a, b) = curve.domain();
3095 let wide = curve.widened((a - 1.5, b + 2.5)).expect("widens");
3096 assert_eq!(wide.domain(), (a - 1.5, b + 2.5));
3097 for k in 0..=8 {
3098 let t = a + (b - a) * f64::from(k) / 8.0;
3099 let gap = wide
3100 .point_at(t, T)
3101 .unwrap()
3102 .distance(curve.point_at(t, T).unwrap());
3103 assert!(gap <= 1e-12, "{gap} at {t}");
3104 }
3105 assert!(curve.widened((a + 0.1, b)).is_none(), "a narrower range");
3106 let reversed = curve.reversed().widened((a - 1.0, b + 1.0));
3108 assert_eq!(reversed.is_some(), matches!(curve, Curve::Line(_)));
3109 }
3110 let circle: Curve =
3111 CircleCurve::new(ogeom_math::Circle::new(frame, 2.0, T).unwrap()).into();
3112 let (a, b) = circle.domain();
3113 assert!(circle.widened((a - 1.0, b + 1.0)).is_none());
3114 }
3115}