1use core::f64::consts::TAU;
35
36use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
37use ogeom_math::{Frame, KnotVector, Point, Weighted};
38
39use crate::curve::{BSplineCurve, Curve};
40use crate::fit::{self, Fitted};
41use crate::traits::{Curve3d, Surface};
42use ogeom_core::ogeom_err;
43
44const MAX_SPAN: f64 = core::f64::consts::FRAC_PI_2;
50
51impl Curve {
52 pub fn to_bspline(&self, tol: Tolerances) -> OgeomResult<BSplineCurve> {
64 let (lo, hi) = self.domain();
65 self.to_bspline_over((lo, hi), tol)
66 }
67
68 pub fn to_bspline_over(&self, range: (f64, f64), tol: Tolerances) -> OgeomResult<BSplineCurve> {
79 let (lo, hi) = range;
80 if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
81 ogeom_bail!(Construction, "cannot convert an empty range [{lo}, {hi}]");
82 }
83 match self {
84 Self::Line(_) => segment(self.point_at(lo, tol)?, self.point_at(hi, tol)?),
87
88 Self::Circle(c) => {
89 let circle = c.circle();
90 let (a, b) = oriented(lo, hi, c.is_reversed());
91 conic_arc(circle.frame(), circle.radius(), circle.radius(), a, b, tol)
92 }
93 Self::Ellipse(e) => {
94 let ellipse = e.ellipse();
95 let (a, b) = oriented(lo, hi, e.is_reversed());
96 conic_arc(
97 ellipse.frame(),
98 ellipse.major_radius(),
99 ellipse.minor_radius(),
100 a,
101 b,
102 tol,
103 )
104 }
105
106 Self::Parabola(_) | Self::Hyperbola(_) => tangent_quadratic(self, lo, hi, tol),
110
111 Self::BSpline(s) => {
114 let (a, b) = s.knots().domain();
115 let mut out = s.clone();
116 if hi < b - tol.parametric() {
117 out = out.split_at(hi, tol)?.0;
118 }
119 if lo > a + tol.parametric() {
120 out = out.split_at(lo, tol)?.1;
121 }
122 normalized(out)
123 }
124
125 Self::Helix(_) => ogeom_bail!(
129 Construction,
130 "a helix has no exact B-spline form; fit it at a stated tolerance instead"
131 ),
132
133 Self::Offset(_) => ogeom_bail!(
136 Construction,
137 "an offset curve has no exact B-spline form; fit it at a stated tolerance instead"
138 ),
139 Self::OnSurface(_) => ogeom_bail!(
140 Construction,
141 "a surface curve has no exact B-spline form in general; fit it at a stated \
142 tolerance instead"
143 ),
144
145 Self::Trimmed(t) => {
146 let (ta, tb) = t.domain();
150 if !t.is_reversed() {
151 return t.basis().to_bspline_over((lo, hi), tol);
152 }
153 let at = |u: f64| ta + tb - u;
154 let forwards = t.basis().to_bspline_over((at(hi), at(lo)), tol)?;
157 reverse(&forwards)
158 }
159 }
160 }
161}
162
163fn segment(from: Point, to: Point) -> OgeomResult<BSplineCurve> {
165 let knots = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
166 BSplineCurve::rational(
167 knots,
168 vec![
169 Weighted {
170 scaled: from,
171 weight: 1.0,
172 },
173 Weighted {
174 scaled: to,
175 weight: 1.0,
176 },
177 ],
178 )
179}
180
181const fn oriented(lo: f64, hi: f64, reversed: bool) -> (f64, f64) {
187 if reversed { (-lo, -hi) } else { (lo, hi) }
188}
189
190fn conic_arc(
198 frame: Frame,
199 major: f64,
200 minor: f64,
201 from: f64,
202 to: f64,
203 tol: Tolerances,
204) -> OgeomResult<BSplineCurve> {
205 let sweep = to - from;
206 if sweep.abs() > TAU + tol.parametric() {
207 ogeom_bail!(
208 Construction,
209 "an arc of {sweep} radians covers the conic more than once"
210 );
211 }
212 #[allow(
213 clippy::cast_possible_truncation,
214 clippy::cast_sign_loss,
215 clippy::cast_precision_loss,
216 reason = "a span count bounded by four; the ceiling below is exact"
217 )]
218 let spans = ((sweep.abs() / MAX_SPAN).ceil() as usize).max(1);
219 #[allow(clippy::cast_precision_loss)]
220 let step = sweep / spans as f64;
221 let half = step * 0.5;
225 let (cos_half, reach) = (half.cos(), 1.0 / half.cos());
226 if cos_half <= tol.confusion() {
227 ogeom_bail!(
228 Construction,
229 "a span of {step} radians is too wide for one rational quadratic"
230 );
231 }
232
233 let at = |angle: f64| frame.to_world(Point::new(major * angle.cos(), minor * angle.sin(), 0.0));
234 let shoulder = |angle: f64| {
235 frame.to_world(Point::new(
236 major * reach * angle.cos(),
237 minor * reach * angle.sin(),
238 0.0,
239 ))
240 };
241
242 let mut control = Vec::with_capacity(2 * spans + 1);
243 control.push(Weighted {
244 scaled: at(from),
245 weight: 1.0,
246 });
247 for k in 0..spans {
248 #[allow(clippy::cast_precision_loss)]
249 let start = from + step * k as f64;
250 let middle = start + half;
251 let end = start + step;
252 control.push(Weighted {
256 scaled: Point::from_vector(shoulder(middle).to_vector() * cos_half),
257 weight: cos_half,
258 });
259 control.push(Weighted {
260 scaled: at(end),
261 weight: 1.0,
262 });
263 }
264
265 let mut knots = vec![0.0, 0.0, 0.0];
266 for k in 1..spans {
267 #[allow(clippy::cast_precision_loss)]
268 let at_knot = k as f64 / spans as f64;
269 knots.push(at_knot);
270 knots.push(at_knot);
271 }
272 knots.extend([1.0, 1.0, 1.0]);
273 BSplineCurve::rational(KnotVector::new(knots, 2)?, control)
274}
275
276fn tangent_quadratic(
285 curve: &Curve,
286 lo: f64,
287 hi: f64,
288 tol: Tolerances,
289) -> OgeomResult<BSplineCurve> {
290 let (start, end) = (curve.point_at(lo, tol)?, curve.point_at(hi, tol)?);
291 let (ta, tb) = (curve.d1_at(lo, tol)?, curve.d1_at(hi, tol)?);
292
293 let Some(shoulder) = meet(start, ta, end, tb, tol) else {
296 ogeom_bail!(
297 Construction,
298 "the end tangents of this span are parallel, so it has no quadratic \
299 form; split the range"
300 );
301 };
302
303 let middle = curve.point_at(f64::midpoint(lo, hi), tol)?;
308 let numerator = (start.to_vector() + end.to_vector()) * 0.5 - middle.to_vector();
309 let denominator = middle.to_vector() - shoulder.to_vector();
310 let weight = if denominator.magnitude() <= tol.confusion() {
311 1.0
312 } else {
313 let w = numerator.dot(denominator) / denominator.square_magnitude();
314 if !w.is_finite() || w <= tol.confusion() {
315 ogeom_bail!(
316 Construction,
317 "this span has no rational quadratic form; split the range"
318 );
319 }
320 w
321 };
322
323 let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2)?;
324 BSplineCurve::rational(
325 knots,
326 vec![
327 Weighted {
328 scaled: start,
329 weight: 1.0,
330 },
331 Weighted {
332 scaled: Point::from_vector(shoulder.to_vector() * weight),
333 weight,
334 },
335 Weighted {
336 scaled: end,
337 weight: 1.0,
338 },
339 ],
340 )
341}
342
343fn meet(
345 a: Point,
346 along_a: ogeom_math::Vector,
347 b: Point,
348 along_b: ogeom_math::Vector,
349 tol: Tolerances,
350) -> Option<Point> {
351 let between = b - a;
352 let cross = along_a.cross(along_b);
353 let denominator = cross.square_magnitude();
354 if denominator <= tol.confusion() * tol.confusion() {
355 return None;
356 }
357 let t = between.cross(along_b).dot(cross) / denominator;
358 let found = a + along_a * t;
359 let s = between.cross(along_a).dot(cross) / denominator;
362 if found.distance(b + along_b * s) > tol.confusion() {
363 return None;
364 }
365 Some(found)
366}
367
368fn reverse(curve: &BSplineCurve) -> OgeomResult<BSplineCurve> {
373 let (a, b) = curve.knots().domain();
374 let mut knots: Vec<f64> = curve.knots().knots().iter().map(|k| a + b - k).collect();
375 knots.reverse();
376 let mut control = curve.control_points().to_vec();
377 control.reverse();
378 BSplineCurve::rational(KnotVector::new(knots, curve.degree())?, control)
379}
380
381impl Curve {
383 pub fn fitted_bspline_over(
402 &self,
403 range: (f64, f64),
404 tolerance: f64,
405 tol: Tolerances,
406 ) -> OgeomResult<Fitted<BSplineCurve>> {
407 same_parameter_fit(self, range, 3, tolerance, tol)
408 }
409}
410
411impl BSplineCurve {
412 pub fn restricted_to_degree(
423 &self,
424 max_degree: usize,
425 tolerance: f64,
426 tol: Tolerances,
427 ) -> OgeomResult<Fitted<Self>> {
428 if max_degree == 0 {
429 ogeom_bail!(Construction, "a curve needs a degree of at least one");
430 }
431 if self.degree() <= max_degree {
432 return Ok(Fitted {
433 curve: self.clone(),
434 error: 0.0,
435 met: true,
436 });
437 }
438 if self.is_periodic() {
439 ogeom_bail!(
440 Construction,
441 "a periodic curve has no ends to fit between; reseam it first"
442 );
443 }
444 let domain = self.domain();
445 same_parameter_fit(
446 &Curve::BSpline(self.clone()),
447 domain,
448 max_degree,
449 tolerance,
450 tol,
451 )
452 }
453}
454
455impl crate::surface::BSplineSurface {
456 pub fn restricted_to_degree(
470 &self,
471 max_degree: usize,
472 tolerance: f64,
473 tol: Tolerances,
474 ) -> OgeomResult<Fitted<Self>> {
475 if max_degree == 0 {
476 ogeom_bail!(Construction, "a patch needs a degree of at least one");
477 }
478 if self.u_knots().degree() <= max_degree && self.v_knots().degree() <= max_degree {
479 return Ok(Fitted {
480 curve: self.clone(),
481 error: 0.0,
482 met: true,
483 });
484 }
485 grid_fitted(
486 |u, v| self.point_at(u, v, tol),
487 self.domain(),
488 max_degree,
489 tolerance,
490 tol,
491 )
492 }
493}
494
495fn grid_fitted(
500 point: impl Fn(f64, f64) -> OgeomResult<Point>,
501 domain: ((f64, f64), (f64, f64)),
502 degree: usize,
503 tolerance: f64,
504 tol: Tolerances,
505) -> OgeomResult<Fitted<crate::surface::BSplineSurface>> {
506 if !(tolerance > 0.0 && tolerance.is_finite()) {
507 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
508 }
509 let ((ua, ub), (va, vb)) = domain;
510 if ![ua, ub, va, vb].iter().all(|x| x.is_finite()) {
511 ogeom_bail!(Construction, "an unbounded surface cannot be fitted");
512 }
513 let mut samples = 16usize;
514 let mut best: Option<Fitted<crate::surface::BSplineSurface>> = None;
515 for _ in 0..4 {
516 let mut rows: Vec<Vec<Point>> = Vec::with_capacity(samples + 1);
517 for j in 0..=samples {
518 #[allow(clippy::cast_precision_loss)]
519 let v = va + (vb - va) * j as f64 / samples as f64;
520 let mut row = Vec::with_capacity(samples + 1);
521 for i in 0..=samples {
522 #[allow(clippy::cast_precision_loss)]
523 let u = ua + (ub - ua) * i as f64 / samples as f64;
524 row.push(point(u, v)?);
525 }
526 rows.push(row);
527 }
528 let fitted = fit::fit_surface_grid(&rows, degree, tolerance, tol)?;
529 if fitted.met {
530 return Ok(fitted);
531 }
532 if best.as_ref().is_none_or(|b| fitted.error < b.error) {
533 best = Some(fitted);
534 }
535 samples *= 2;
536 }
537 best.ok_or_else(|| ogeom_err!(Construction, "the patch could not be sampled"))
538}
539
540impl crate::surface::SurfaceGeometry {
541 pub fn fitted_bspline(
556 &self,
557 tolerance: f64,
558 tol: Tolerances,
559 ) -> OgeomResult<Fitted<crate::surface::BSplineSurface>> {
560 use crate::traits::Surface as _;
561 grid_fitted(
562 |u, v| self.point_at(u, v, tol),
563 self.domain(),
564 3,
565 tolerance,
566 tol,
567 )
568 }
569}
570
571fn same_parameter_fit(
575 curve: &Curve,
576 range: (f64, f64),
577 degree: usize,
578 tolerance: f64,
579 tol: Tolerances,
580) -> OgeomResult<Fitted<BSplineCurve>> {
581 let (lo, hi) = range;
582 if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
583 ogeom_bail!(Construction, "cannot fit over an empty range [{lo}, {hi}]");
584 }
585 if !(tolerance > 0.0 && tolerance.is_finite()) {
586 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
587 }
588 let mut samples = 32usize;
589 let mut best: Option<Fitted<BSplineCurve>> = None;
590 for _ in 0..8 {
591 #[allow(clippy::cast_precision_loss)]
592 let params: Vec<f64> = (0..=samples)
593 .map(|i| lo + (hi - lo) * i as f64 / samples as f64)
594 .collect();
595 let points = params
596 .iter()
597 .map(|t| curve.point_at(*t, tol))
598 .collect::<OgeomResult<Vec<Point>>>()?;
599 let fitted = fit::fit_points_at(¶ms, &points, degree, tolerance, tol)?;
600 let mut error = fitted.error;
601 for pair in params.windows(2) {
602 let t = f64::midpoint(pair[0], pair[1]);
603 error = error.max(
604 curve
605 .point_at(t, tol)?
606 .distance(fitted.curve.point_at(t, tol)?),
607 );
608 }
609 let candidate = Fitted {
610 curve: fitted.curve,
611 error,
612 met: error <= tolerance,
613 };
614 if candidate.met {
615 return Ok(candidate);
616 }
617 if best.as_ref().is_none_or(|b| error < b.error) {
618 best = Some(candidate);
619 }
620 samples *= 2;
621 }
622 best.ok_or_else(|| ogeom_err!(Construction, "the curve could not be sampled"))
623}
624
625fn normalized(curve: BSplineCurve) -> OgeomResult<BSplineCurve> {
626 let knots = curve.knots().reparameterized(0.0, 1.0)?;
627 BSplineCurve::rational(knots, curve.control_points().to_vec())
628}
629
630#[cfg(test)]
631#[allow(clippy::unwrap_used)]
632mod tests {
633 use super::*;
634 use core::f64::consts::{FRAC_PI_2, PI};
635 use ogeom_math::{Circle, Direction, Ellipse, Hyperbola, Parabola, Vector};
636
637 use crate::curve::{
638 CircleCurve, EllipseCurve, HyperbolaCurve, LineCurve, ParabolaCurve, TrimmedCurve,
639 };
640
641 const T: Tolerances = Tolerances::millimetres();
642
643 #[test]
648 fn a_helix_fits_to_a_stated_tolerance_at_its_own_parameters() {
649 let helix: Curve = crate::curve::HelixCurve::new(ogeom_math::Frame::WORLD, 5.0, 4.0, 2.0)
650 .unwrap()
651 .into();
652 assert!(helix.to_bspline(T).is_err(), "the exact conversion refuses");
653 let domain = helix.domain();
654 let fitted = helix.fitted_bspline_over(domain, 1e-3, T).unwrap();
655 assert!(fitted.met && fitted.error <= 1e-3, "error {}", fitted.error);
656 let (lo, hi) = fitted.curve.domain();
657 assert!((lo - domain.0).abs() < 1e-12 && (hi - domain.1).abs() < 1e-12);
658 for i in 0..=200 {
659 let t = domain.0 + (domain.1 - domain.0) * f64::from(i) / 200.0;
660 let gap = helix
661 .point_at(t, T)
662 .unwrap()
663 .distance(fitted.curve.point_at(t, T).unwrap());
664 assert!(gap <= 1e-3, "same-parameter within tolerance at {t}: {gap}");
665 }
666 }
667
668 #[test]
671 fn a_curve_restricted_in_degree_holds_its_tolerance() {
672 let cubic = BSplineCurve::new(
673 ogeom_math::KnotVector::clamped_uniform(3, 6).unwrap(),
674 vec![
675 Point::new(0.0, 0.0, 0.0),
676 Point::new(1.0, 2.0, 0.5),
677 Point::new(2.5, 1.0, -0.5),
678 Point::new(4.0, 3.0, 1.0),
679 Point::new(5.0, 0.5, 0.0),
680 Point::new(6.0, 2.0, 2.0),
681 ],
682 T,
683 )
684 .unwrap();
685 let quintic = cubic.elevated(T).unwrap().elevated(T).unwrap();
686 assert_eq!(quintic.degree(), 5);
687 let same = quintic.restricted_to_degree(5, 1e-6, T).unwrap();
688 assert!(same.met && same.error == 0.0 && same.curve.degree() == 5);
689 let back = quintic.restricted_to_degree(3, 1e-6, T).unwrap();
690 assert!(back.met && back.curve.degree() == 3, "error {}", back.error);
691 let (lo, hi) = cubic.domain();
692 for i in 0..=50 {
693 let t = lo + (hi - lo) * f64::from(i) / 50.0;
694 let gap = cubic
695 .point_at(t, T)
696 .unwrap()
697 .distance(back.curve.point_at(t, T).unwrap());
698 assert!(gap < 1e-6, "the cubic again at {t}: {gap}");
699 }
700 }
701
702 #[test]
705 fn a_patch_restricted_in_degree_holds_its_samples() {
706 let (nu, nv) = (5, 4);
707 let mut points = Vec::with_capacity(nu * nv);
708 for i in 0..nu {
709 for j in 0..nv {
710 #[allow(clippy::cast_precision_loss)]
711 let (x, y) = (i as f64, j as f64);
712 points.push(Point::new(x, y, (x * 0.7).sin() * (y * 0.5).cos() * 0.3));
713 }
714 }
715 let patch = crate::surface::BSplineSurface::new(
716 ogeom_math::KnotVector::clamped_uniform(3, nu).unwrap(),
717 ogeom_math::KnotVector::clamped_uniform(3, nv).unwrap(),
718 &ogeom_math::ControlGrid::new(points, nu, nv).unwrap(),
719 T,
720 )
721 .unwrap();
722 let lower = patch.restricted_to_degree(2, 1e-2, T).unwrap();
723 assert!(lower.met && lower.error <= 1e-2, "error {}", lower.error);
724 assert!(lower.curve.u_knots().degree() == 2 && lower.curve.v_knots().degree() == 2);
725 let same = patch.restricted_to_degree(3, 1e-2, T).unwrap();
726 assert!(same.met && same.error == 0.0);
727 }
728
729 fn deviation(original: &Curve, converted: &BSplineCurve, samples: usize) -> f64 {
736 let (a, b) = converted.knots().domain();
737 let mut worst = 0.0_f64;
738 for i in 0..=samples {
739 #[allow(clippy::cast_precision_loss)]
740 let u = a + (b - a) * i as f64 / samples as f64;
741 let p = converted.point_at(u, T).unwrap();
742 worst = worst.max(distance_to(original, p));
743 }
744 worst
745 }
746
747 fn distance_to(curve: &Curve, p: Point) -> f64 {
749 match curve {
750 Curve::Circle(c) => c.circle().distance_to(p),
751 Curve::Ellipse(e) => {
752 nearest(curve, p, e.ellipse().major_radius())
754 }
755 _ => nearest(curve, p, 1.0),
756 }
757 }
758
759 fn nearest(curve: &Curve, p: Point, _scale: f64) -> f64 {
767 const SCAN: usize = 2_000;
768 let (a, b) = curve.domain();
769 let at = |u: f64| curve.point_at(u, T).map_or(f64::MAX, |q| p.distance(q));
770
771 let mut best = (a, f64::MAX);
772 for i in 0..=SCAN {
773 #[allow(clippy::cast_precision_loss)]
774 let u = a + (b - a) * i as f64 / SCAN as f64;
775 let d = at(u);
776 if d < best.1 {
777 best = (u, d);
778 }
779 }
780 #[allow(clippy::cast_precision_loss)]
783 let step = (b - a) / SCAN as f64;
784 let (mut lo, mut hi) = (best.0 - step, best.0 + step);
785 for _ in 0..200 {
786 let one = lo + (hi - lo) / 3.0;
787 let two = hi - (hi - lo) / 3.0;
788 if at(one) < at(two) {
789 hi = two;
790 } else {
791 lo = one;
792 }
793 }
794 at(f64::midpoint(lo, hi)).min(best.1)
795 }
796
797 #[test]
798 fn a_line_becomes_a_degree_one_spline_through_its_own_ends() {
799 let from = Point::new(1.0, 2.0, 3.0);
800 let to = Point::new(4.0, -1.0, 0.5);
801 let line: Curve = LineCurve::segment(from, to, T).unwrap().into();
802 let spline = line.to_bspline(T).unwrap();
803
804 assert_eq!(spline.degree(), 1);
805 assert!(!spline.is_rational(), "a line needs no weights");
806 assert!(spline.point_at(0.0, T).unwrap().is_equal(from, T));
807 assert!(spline.point_at(1.0, T).unwrap().is_equal(to, T));
808 assert!(spline.point_at(0.5, T).unwrap().is_equal(
809 Point::from_vector((from.to_vector() + to.to_vector()) * 0.5),
810 T
811 ));
812 }
813
814 #[test]
815 fn a_full_circle_becomes_an_exact_rational_quadratic() {
816 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.5, T).unwrap()).into();
820 let spline = circle.to_bspline(T).unwrap();
821
822 assert_eq!(spline.degree(), 2);
823 assert!(spline.is_rational(), "a circle needs its weights");
824 assert!(
825 deviation(&circle, &spline, 500) < 1e-12,
826 "off the circle by {}",
827 deviation(&circle, &spline, 500)
828 );
829 assert!(
831 spline
832 .point_at(0.0, T)
833 .unwrap()
834 .is_equal(spline.point_at(1.0, T).unwrap(), T)
835 );
836 }
837
838 #[test]
839 fn an_arc_covers_its_own_span_and_no_more() {
840 let circle = Circle::new(Frame::WORLD, 3.0, T).unwrap();
843 let curve: Curve = CircleCurve::new(circle).into();
844 for (from, to) in [
845 (0.0, FRAC_PI_2),
846 (0.3, 1.9),
847 (PI, PI * 1.5),
848 (0.0, PI * 1.75),
849 ] {
850 let spline = curve.to_bspline_over((from, to), T).unwrap();
851 assert!(
852 spline
853 .point_at(0.0, T)
854 .unwrap()
855 .is_equal(curve.point_at(from, T).unwrap(), T),
856 "arc [{from}, {to}] starts in the wrong place"
857 );
858 assert!(
859 spline
860 .point_at(1.0, T)
861 .unwrap()
862 .is_equal(curve.point_at(to, T).unwrap(), T),
863 "arc [{from}, {to}] ends in the wrong place"
864 );
865 assert!(deviation(&curve, &spline, 200) < 1e-12);
866 }
867 }
868
869 #[test]
870 fn an_ellipse_uses_the_same_construction_and_the_same_weights() {
871 let ellipse: Curve =
875 EllipseCurve::new(Ellipse::new(Frame::WORLD, 5.0, 2.0, T).unwrap()).into();
876 let spline = ellipse.to_bspline(T).unwrap();
877 assert_eq!(spline.degree(), 2);
878 assert!(deviation(&ellipse, &spline, 400) < 1e-9);
879
880 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 5.0, T).unwrap()).into();
881 let round = circle.to_bspline(T).unwrap();
882 let weights: Vec<f64> = spline.control_points().iter().map(|c| c.weight).collect();
883 let same: Vec<f64> = round.control_points().iter().map(|c| c.weight).collect();
884 assert_eq!(weights, same, "the weights should not depend on the radii");
885 }
886
887 #[test]
888 fn a_conic_in_a_tilted_frame_converts_where_it_actually_is() {
889 let frame = Frame::new(
890 Point::new(3.0, -2.0, 7.0),
891 Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
892 Direction::from_coords(1.0, -1.0, 0.0, T).unwrap(),
893 T,
894 )
895 .unwrap();
896 let circle: Curve = CircleCurve::new(Circle::new(frame, 4.0, T).unwrap()).into();
897 let spline = circle.to_bspline(T).unwrap();
898 assert!(deviation(&circle, &spline, 300) < 1e-12);
899 }
900
901 #[test]
902 fn a_parabola_is_a_polynomial_quadratic_exactly() {
903 let parabola: Curve = ParabolaCurve::new(Parabola::new(Frame::WORLD, 1.5, T).unwrap(), 4.0)
906 .unwrap()
907 .into();
908 let spline = parabola.to_bspline(T).unwrap();
909 assert_eq!(spline.degree(), 2);
910 assert!(deviation(¶bola, &spline, 300) < 1e-9);
911 for c in spline.control_points() {
912 assert!(
913 (c.weight - 1.0).abs() < 1e-9,
914 "a parabola should need no weights, got {}",
915 c.weight
916 );
917 }
918 }
919
920 #[test]
921 fn a_hyperbola_becomes_a_rational_quadratic() {
922 let hyperbola: Curve =
923 HyperbolaCurve::new(Hyperbola::new(Frame::WORLD, 2.0, 1.0, T).unwrap(), 1.0)
924 .unwrap()
925 .into();
926 let spline = hyperbola.to_bspline(T).unwrap();
927 assert_eq!(spline.degree(), 2);
928 assert!(
929 deviation(&hyperbola, &spline, 300) < 1e-9,
930 "off by {}",
931 deviation(&hyperbola, &spline, 300)
932 );
933 }
934
935 #[test]
936 fn a_spline_converts_to_itself_and_a_trimmed_one_to_its_piece() {
937 let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0], 2).unwrap();
938 let control = vec![
939 Point::ORIGIN,
940 Point::new(1.0, 2.0, 0.0),
941 Point::new(3.0, 2.0, 1.0),
942 Point::new(4.0, 0.0, 0.0),
943 ];
944 let spline: Curve = BSplineCurve::new(knots, control, T).unwrap().into();
945 let same = spline.to_bspline(T).unwrap();
946 assert!(deviation(&spline, &same, 200) < 1e-12);
947
948 let trimmed: Curve = Curve::Trimmed(Box::new(
949 TrimmedCurve::new(spline.clone(), 0.25, 0.75, T).unwrap(),
950 ));
951 let piece = trimmed.to_bspline(T).unwrap();
952 assert!(
953 piece
954 .point_at(0.0, T)
955 .unwrap()
956 .is_equal(spline.point_at(0.25, T).unwrap(), T)
957 );
958 assert!(
959 piece
960 .point_at(1.0, T)
961 .unwrap()
962 .is_equal(spline.point_at(0.75, T).unwrap(), T)
963 );
964 assert!(deviation(&spline, &piece, 200) < 1e-12);
965 }
966
967 #[test]
968 fn a_reversed_conic_converts_to_the_curve_it_actually_traces() {
969 use crate::traits::Reversible;
970 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
971 let backwards = circle.reversed();
972 let spline = backwards.to_bspline_over((0.0, FRAC_PI_2), T).unwrap();
973
974 assert!(
975 spline
976 .point_at(0.0, T)
977 .unwrap()
978 .is_equal(backwards.point_at(0.0, T).unwrap(), T),
979 "a reversed arc should start where the reversed curve does"
980 );
981 assert!(
982 spline
983 .point_at(1.0, T)
984 .unwrap()
985 .is_equal(backwards.point_at(FRAC_PI_2, T).unwrap(), T)
986 );
987 }
988
989 #[test]
990 fn an_empty_range_is_refused() {
991 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
992 assert!(circle.to_bspline_over((1.0, 1.0), T).is_err());
993 assert!(circle.to_bspline_over((1.0, 0.0), T).is_err());
994 assert!(circle.to_bspline_over((0.0, f64::NAN), T).is_err());
995 let _ = Vector::ZERO;
996 }
997}
998
999impl crate::surface::SurfaceGeometry {
1002 pub fn to_bspline(&self, tol: Tolerances) -> OgeomResult<crate::surface::BSplineSurface> {
1019 self.to_bspline_over(self.domain(), tol)
1020 }
1021
1022 fn to_bspline_over(
1031 &self,
1032 window: ((f64, f64), (f64, f64)),
1033 tol: Tolerances,
1034 ) -> OgeomResult<crate::surface::BSplineSurface> {
1035 use crate::surface::SurfaceGeometry as S;
1036 let ((ua, ub), (va, vb)) = window;
1037 match self {
1038 S::Offset(_) => ogeom_bail!(
1042 Construction,
1043 "an offset surface has no exact B-spline form; offset the basis analytically \
1044 or fit at a stated tolerance instead"
1045 ),
1046
1047 S::Plane(_) => {
1050 let corner = |u: f64, v: f64| self.point_at(u, v, tol);
1051 bilinear(
1052 corner(ua, va)?,
1053 corner(ub, va)?,
1054 corner(ua, vb)?,
1055 corner(ub, vb)?,
1056 )
1057 }
1058
1059 S::Cylinder(_) | S::Cone(_) => {
1062 let profile = |v: f64| -> OgeomResult<Vec<Weighted<Point>>> {
1063 let mut out = Vec::new();
1064 let ring = self.section_at(v, (ua, ub), tol)?;
1065 out.extend_from_slice(ring.control_points());
1066 Ok(out)
1067 };
1068 let (knots, _) = {
1069 let ring = self.section_at(va, (ua, ub), tol)?;
1070 (ring.knots().clone(), ())
1071 };
1072 loft(&knots, &profile(va)?, &profile(vb)?)
1073 }
1074
1075 S::Sphere(sp) => {
1078 let sphere = sp.sphere();
1079 let frame = sphere.frame();
1080 let meridian = Frame::new(frame.origin(), -frame.y(), frame.x(), tol)?;
1084 let circle = ogeom_math::Circle::new(meridian, sphere.radius(), tol)?;
1085 let profile: Curve = crate::curve::CircleCurve::new(circle).into();
1086 revolved_patch(
1087 &profile,
1088 (va, vb),
1089 ogeom_math::Axis {
1090 location: frame.origin(),
1091 direction: frame.z(),
1092 },
1093 (ua, ub),
1094 tol,
1095 )
1096 }
1097 S::Torus(t) => {
1098 let torus = t.torus();
1099 let frame = torus.frame();
1100 let centre = frame.origin() + frame.x().vector() * torus.major_radius();
1101 let tube = Frame::new(centre, -frame.y(), frame.x(), tol)?;
1102 let circle = ogeom_math::Circle::new(tube, torus.minor_radius(), tol)?;
1103 let profile: Curve = crate::curve::CircleCurve::new(circle).into();
1104 revolved_patch(
1105 &profile,
1106 (va, vb),
1107 ogeom_math::Axis {
1108 location: frame.origin(),
1109 direction: frame.z(),
1110 },
1111 (ua, ub),
1112 tol,
1113 )
1114 }
1115 S::Revolution(r) => revolved_patch(r.curve(), (va, vb), r.axis(), (ua, ub), tol),
1116
1117 S::Extrusion(e) => {
1118 let base = e.curve().to_bspline_over((ua, ub), tol)?;
1119 let along = e.direction().vector() * (vb - va);
1120 let start: Vec<Weighted<Point>> = base
1121 .control_points()
1122 .iter()
1123 .map(|c| Weighted {
1124 scaled: Point::from_vector(
1125 c.scaled.to_vector() + e.direction().vector() * va * c.weight,
1126 ),
1127 weight: c.weight,
1128 })
1129 .collect();
1130 let end: Vec<Weighted<Point>> = start
1131 .iter()
1132 .map(|c| Weighted {
1133 scaled: Point::from_vector(c.scaled.to_vector() + along * c.weight),
1134 weight: c.weight,
1135 })
1136 .collect();
1137 loft(base.knots(), &start, &end)
1138 }
1139
1140 S::BSpline(s) => {
1141 let whole = s.domain();
1142 let near = |a: (f64, f64), b: (f64, f64)| {
1143 (a.0 - b.0).abs() <= tol.parametric() && (a.1 - b.1).abs() <= tol.parametric()
1144 };
1145 if near(whole.0, (ua, ub)) && near(whole.1, (va, vb)) {
1146 Ok(s.clone())
1147 } else {
1148 s.segment((ua, ub), (va, vb), tol)
1149 }
1150 }
1151
1152 S::Trimmed(t) => t.basis().to_bspline_over(window, tol),
1153 }
1154 }
1155
1156 fn section_at(
1158 &self,
1159 v: f64,
1160 u_range: (f64, f64),
1161 tol: Tolerances,
1162 ) -> OgeomResult<crate::curve::BSplineCurve> {
1163 use crate::surface::SurfaceGeometry as S;
1164 let (frame, radius) = match self {
1165 S::Cylinder(c) => (c.cylinder().frame(), c.cylinder().radius()),
1166 S::Cone(c) => (c.cone().frame(), c.cone().radius_at(v).abs()),
1167 _ => ogeom_bail!(Construction, "this surface has no circular section"),
1168 };
1169 let at = Frame::new(frame.origin() + frame.z() * v, frame.z(), frame.x(), tol)?;
1170 conic_arc(at, radius, radius, u_range.0, u_range.1, tol)
1171 }
1172}
1173
1174fn revolved_patch(
1184 profile: &Curve,
1185 v_range: (f64, f64),
1186 axis: ogeom_math::Axis,
1187 u_range: (f64, f64),
1188 tol: Tolerances,
1189) -> OgeomResult<crate::surface::BSplineSurface> {
1190 let frame = Frame::about(axis.location, axis.direction);
1191 let turn: Curve =
1192 crate::curve::CircleCurve::new(ogeom_math::Circle::new(frame, 1.0, tol)?).into();
1193 let arc = turn.to_bspline_over(u_range, tol)?;
1194 let pro = profile.to_bspline_over(v_range, tol)?;
1195
1196 let locals: Vec<(f64, f64, f64, f64)> = pro
1197 .control_points()
1198 .iter()
1199 .map(|c| {
1200 let l = frame.to_local(Point::from_vector(c.scaled.to_vector() / c.weight));
1201 (l.x, l.y, l.z, c.weight)
1202 })
1203 .collect();
1204 let mut points = Vec::with_capacity(arc.control_points().len() * locals.len());
1205 for ci in arc.control_points() {
1206 let l = frame.to_local(Point::from_vector(ci.scaled.to_vector() / ci.weight));
1207 let (a, b) = (l.x, l.y);
1208 for &(x, y, z, wj) in &locals {
1209 let weight = ci.weight * wj;
1210 let rotated =
1211 frame.to_world(Point::new(a.mul_add(x, -(b * y)), b.mul_add(x, a * y), z));
1212 points.push(Weighted {
1213 scaled: Point::from_vector(rotated.to_vector() * weight),
1214 weight,
1215 });
1216 }
1217 }
1218 let grid = ogeom_math::ControlGrid::new(points, arc.control_points().len(), locals.len())?;
1219 crate::surface::BSplineSurface::rational(arc.knots().clone(), pro.knots().clone(), grid)
1220}
1221
1222fn bilinear(a: Point, b: Point, c: Point, d: Point) -> OgeomResult<crate::surface::BSplineSurface> {
1224 let line = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
1225 let grid = ogeom_math::ControlGrid::new(
1226 vec![
1227 Weighted {
1228 scaled: a,
1229 weight: 1.0,
1230 },
1231 Weighted {
1232 scaled: c,
1233 weight: 1.0,
1234 },
1235 Weighted {
1236 scaled: b,
1237 weight: 1.0,
1238 },
1239 Weighted {
1240 scaled: d,
1241 weight: 1.0,
1242 },
1243 ],
1244 2,
1245 2,
1246 )?;
1247 crate::surface::BSplineSurface::rational(line.clone(), line, grid)
1248}
1249
1250fn loft(
1252 across: &KnotVector,
1253 start: &[Weighted<Point>],
1254 end: &[Weighted<Point>],
1255) -> OgeomResult<crate::surface::BSplineSurface> {
1256 if start.len() != end.len() {
1257 ogeom_bail!(
1258 Dimension,
1259 "a ruled patch needs the same control points at each end, got {} \
1260 and {}",
1261 start.len(),
1262 end.len()
1263 );
1264 }
1265 let mut points = Vec::with_capacity(start.len() * 2);
1266 for (a, b) in start.iter().zip(end) {
1267 points.push(*a);
1268 points.push(*b);
1269 }
1270 let grid = ogeom_math::ControlGrid::new(points, start.len(), 2)?;
1271 let along = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
1272 crate::surface::BSplineSurface::rational(across.clone(), along, grid)
1273}
1274
1275#[cfg(test)]
1276#[allow(clippy::unwrap_used)]
1277mod surface_tests {
1278 use super::*;
1279 use crate::surface::{
1280 ConeSurface, CylinderSurface, ExtrusionSurface, PlaneSurface, SphereSurface,
1281 SurfaceGeometry, TorusSurface, TrimmedSurface,
1282 };
1283 use ogeom_math::{Circle, Cone, Cylinder, Direction, Plane, Sphere, Torus};
1284
1285 const T: Tolerances = Tolerances::millimetres();
1286
1287 fn deviation(distance: impl Fn(Point) -> f64, patch: &crate::surface::BSplineSurface) -> f64 {
1296 let ((pa, pb), (qa, qb)) = patch.domain();
1297 let mut worst = 0.0_f64;
1298 for i in 0..=60 {
1299 for j in 0..=60 {
1300 #[allow(clippy::cast_precision_loss)]
1301 let (s, t) = (i as f64 / 60.0, j as f64 / 60.0);
1302 if let Ok(p) = patch.point_at(pa + (pb - pa) * s, qa + (qb - qa) * t, T) {
1303 worst = worst.max(distance(p).abs());
1304 }
1305 }
1306 }
1307 worst
1308 }
1309
1310 fn spans_the_same(original: &SurfaceGeometry, patch: &crate::surface::BSplineSurface) -> bool {
1315 let ((ua, ub), (va, vb)) = original.domain();
1316 let ((pa, pb), (qa, qb)) = patch.domain();
1317 [
1318 (ua, va, pa, qa),
1319 (ua, vb, pa, qb),
1320 (ub, va, pb, qa),
1321 (ub, vb, pb, qb),
1322 ]
1323 .iter()
1324 .all(|(u, v, p, q)| {
1325 match (original.point_at(*u, *v, T), patch.point_at(*p, *q, T)) {
1326 (Ok(a), Ok(b)) => a.is_equal(b, T),
1327 _ => false,
1328 }
1329 })
1330 }
1331
1332 #[test]
1333 fn a_plane_becomes_a_bilinear_patch() {
1334 let plane: SurfaceGeometry =
1335 PlaneSurface::over(Plane::new(Frame::WORLD), (-2.0, 5.0), (-1.0, 3.0))
1336 .unwrap()
1337 .into();
1338 let patch = plane.to_bspline(T).unwrap();
1339 assert!(!patch.is_rational(), "a plane needs no weights");
1340 let flat = Plane::new(Frame::WORLD);
1341 assert!(deviation(|p| flat.distance_to(p), &patch) < 1e-12);
1342 assert!(spans_the_same(&plane, &patch));
1343 }
1344
1345 #[test]
1346 fn a_cylinder_becomes_an_exact_rational_patch() {
1347 let cylinder: SurfaceGeometry =
1350 CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (0.0, 5.0))
1351 .unwrap()
1352 .into();
1353 let patch = cylinder.to_bspline(T).unwrap();
1354 assert!(patch.is_rational());
1355 let exact = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
1356 let off = deviation(|p| exact.distance_to(p), &patch);
1357 assert!(off < 1e-12, "off the cylinder by {off}");
1358 assert!(spans_the_same(&cylinder, &patch));
1359 }
1360
1361 #[test]
1362 fn a_cone_becomes_an_exact_rational_patch() {
1363 let cone: SurfaceGeometry = ConeSurface::new(
1364 Cone::new(Frame::WORLD, 1.0, 0.4_f64.atan(), T).unwrap(),
1365 (0.0, 4.0),
1366 )
1367 .unwrap()
1368 .into();
1369 let patch = cone.to_bspline(T).unwrap();
1370 let exact = Cone::new(Frame::WORLD, 1.0, 0.4_f64.atan(), T).unwrap();
1371 let off = deviation(|p| exact.distance_to(p), &patch);
1372 assert!(off < 1e-12, "off the cone by {off}");
1373 assert!(spans_the_same(&cone, &patch));
1374 }
1375
1376 #[test]
1377 fn an_extrusion_becomes_its_profile_lofted() {
1378 let circle = crate::curve::CircleCurve::new(Circle::new(Frame::WORLD, 3.0, T).unwrap());
1379 let extrusion: SurfaceGeometry = ExtrusionSurface::new(circle.into(), Direction::Z, 6.0)
1380 .unwrap()
1381 .into();
1382 let patch = extrusion.to_bspline(T).unwrap();
1383 let exact = Cylinder::new(Frame::WORLD, 3.0, T).unwrap();
1386 let off = deviation(|p| exact.distance_to(p), &patch);
1387 assert!(off < 1e-12, "off the swept circle by {off}");
1388 assert!(spans_the_same(&extrusion, &patch));
1389 }
1390
1391 #[test]
1392 fn a_patch_converts_to_itself() {
1393 let cylinder: SurfaceGeometry =
1394 CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.0, T).unwrap(), (0.0, 1.0))
1395 .unwrap()
1396 .into();
1397 let patch = cylinder.to_bspline(T).unwrap();
1398 let again: SurfaceGeometry = patch.clone().into();
1399 let twice = again.to_bspline(T).unwrap();
1400 assert_eq!(patch, twice);
1401 }
1402
1403 #[test]
1404 fn a_sphere_becomes_an_exact_rational_patch() {
1405 let sphere = Sphere::new(Frame::WORLD, 2.5, T).unwrap();
1406 let surface: SurfaceGeometry = SphereSurface::new(sphere).into();
1407 let patch = surface.to_bspline(T).unwrap();
1408 assert!(patch.is_rational(), "a sphere needs weights");
1409 assert!(deviation(|p| sphere.distance_to(p), &patch) < 1e-12);
1410 assert!(spans_the_same(&surface, &patch));
1411 }
1412
1413 #[test]
1414 fn a_torus_becomes_an_exact_rational_patch() {
1415 let torus = Torus::new(Frame::WORLD, 3.0, 1.0, T).unwrap();
1416 let surface: SurfaceGeometry = TorusSurface::new(torus).into();
1417 let patch = surface.to_bspline(T).unwrap();
1418 assert!(patch.is_rational(), "a torus needs weights");
1419 assert!(deviation(|p| torus.distance_to(p), &patch) < 1e-12);
1420 assert!(spans_the_same(&surface, &patch));
1421 }
1422
1423 #[test]
1424 fn a_revolution_becomes_the_exact_patch_its_own_construction_is() {
1425 use crate::curve::LineCurve;
1430 let line =
1431 LineCurve::segment(Point::new(2.0, 0.0, 0.0), Point::new(2.0, 0.0, 5.0), T).unwrap();
1432 let surface: SurfaceGeometry = crate::surface::RevolutionSurface::new(
1433 line.into(),
1434 ogeom_math::Axis {
1435 location: Point::new(0.0, 0.0, 0.0),
1436 direction: ogeom_math::Direction::Z,
1437 },
1438 1.5 * core::f64::consts::PI,
1439 )
1440 .unwrap()
1441 .into();
1442 let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
1443 let patch = surface.to_bspline(T).unwrap();
1444 assert!(deviation(|p| cylinder.distance_to(p), &patch) < 1e-12);
1445 assert!(spans_the_same(&surface, &patch));
1446 }
1447
1448 #[test]
1449 fn a_trimmed_surface_converts_as_its_basis_over_the_window() {
1450 let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
1451 let trimmed: SurfaceGeometry = SurfaceGeometry::Trimmed(Box::new(
1452 TrimmedSurface::new(plane, (0.0, 1.0), (2.0, 5.0), T).unwrap(),
1453 ));
1454 let patch = trimmed.to_bspline(T).unwrap();
1455 let flat = Plane::new(Frame::WORLD);
1456 assert!(deviation(|p| flat.distance_to(p), &patch) < 1e-12);
1457 assert!(spans_the_same(&trimmed, &patch));
1458 }
1459
1460 #[test]
1461 fn a_trimmed_spline_converts_to_its_piece() {
1462 let plane: SurfaceGeometry =
1463 PlaneSurface::over(Plane::new(Frame::WORLD), (0.0, 4.0), (0.0, 4.0))
1464 .unwrap()
1465 .into();
1466 let spline: SurfaceGeometry = plane.to_bspline(T).unwrap().into();
1467 let trimmed: SurfaceGeometry = SurfaceGeometry::Trimmed(Box::new(
1468 TrimmedSurface::new(spline, (0.25, 0.5), (0.0, 0.75), T).unwrap(),
1469 ));
1470 let patch = trimmed.to_bspline(T).unwrap();
1471 assert!(spans_the_same(&trimmed, &patch));
1472 }
1473
1474 #[test]
1475 fn a_spline_segment_keeps_its_parameters() {
1476 let cylinder: SurfaceGeometry = CylinderSurface::new(
1477 ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap(),
1478 (0.0, 3.0),
1479 )
1480 .unwrap()
1481 .into();
1482 let whole = cylinder.to_bspline(T).unwrap();
1483 let ((ua, ub), (va, vb)) = whole.domain();
1484 let u = (ua + 0.2 * (ub - ua), ua + 0.7 * (ub - ua));
1485 let v = (va + 0.1 * (vb - va), va + 0.6 * (vb - va));
1486 let piece = whole.segment(u, v, T).unwrap();
1487 assert_eq!(piece.domain(), (u, v));
1488 for i in 0..=8 {
1489 for j in 0..=8 {
1490 let s = u.0 + (u.1 - u.0) * f64::from(i) / 8.0;
1491 let t = v.0 + (v.1 - v.0) * f64::from(j) / 8.0;
1492 let off = piece
1493 .point_at(s, t, T)
1494 .unwrap()
1495 .distance(whole.point_at(s, t, T).unwrap());
1496 assert!(off < 1e-12, "{off} off at ({s}, {t})");
1497 }
1498 }
1499 }
1500}
1501
1502impl Curve {
1505 pub fn general_transformed(
1527 &self,
1528 t: &ogeom_math::GeneralTransform,
1529 tol: Tolerances,
1530 ) -> OgeomResult<BSplineCurve> {
1531 let spline = self.to_bspline(tol)?;
1532 let moved: Vec<Weighted<Point>> = spline
1533 .control_points()
1534 .iter()
1535 .map(|c| {
1536 let position = c.point();
1542 Weighted {
1543 scaled: Point::from_vector(t.apply(position).to_vector() * c.weight),
1544 weight: c.weight,
1545 }
1546 })
1547 .collect();
1548 if moved
1549 .iter()
1550 .all(|c| c.point().is_equal(moved[0].point(), tol))
1551 {
1552 ogeom_bail!(
1553 Construction,
1554 "this transform collapses the curve to a point; it is singular \
1555 in the curve's own directions"
1556 );
1557 }
1558 BSplineCurve::rational(spline.knots().clone(), moved)
1559 }
1560}
1561
1562#[cfg(test)]
1563#[allow(clippy::unwrap_used)]
1564mod affine_tests {
1565 use super::*;
1566 use crate::curve::{CircleCurve, LineCurve};
1567 use ogeom_math::{Circle, GeneralTransform, Matrix3, Vector};
1568
1569 const T: Tolerances = Tolerances::millimetres();
1570
1571 #[test]
1572 fn an_uneven_scale_carries_a_circle_onto_the_ellipse_it_should() {
1573 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1577 let stretch = GeneralTransform::new(
1578 Matrix3::new([[3.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
1579 Vector::ZERO,
1580 );
1581 let moved = circle.general_transformed(&stretch, T).unwrap();
1582
1583 let (a, b) = moved.knots().domain();
1584 for i in 0..=400 {
1585 #[allow(clippy::cast_precision_loss)]
1586 let u = a + (b - a) * i as f64 / 400.0;
1587 let p = moved.point_at(u, T).unwrap();
1588 let on_ellipse = (p.x / 3.0).powi(2) + p.y.powi(2);
1589 assert!(
1590 (on_ellipse - 1.0).abs() < 1e-12,
1591 "at {u} the point {p:?} is not on the ellipse: {on_ellipse}"
1592 );
1593 }
1594 }
1595
1596 #[test]
1597 fn a_shear_is_exact_because_a_spline_is_an_affine_combination() {
1598 let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(2.0, 3.0, 0.0), T)
1602 .unwrap()
1603 .into();
1604 let shear = GeneralTransform::new(
1605 Matrix3::new([[1.0, 0.7, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
1606 Vector::new(1.0, 2.0, 3.0),
1607 );
1608 let moved = line.general_transformed(&shear, T).unwrap();
1609
1610 for i in 0..=50 {
1611 #[allow(clippy::cast_precision_loss)]
1612 let u = i as f64 / 50.0;
1613 let before =
1614 line.point_at(line.domain().0 + (line.domain().1 - line.domain().0) * u, T);
1615 let after = moved.point_at(u, T).unwrap();
1616 assert!(shear.apply(before.unwrap()).is_equal(after, T));
1617 }
1618 }
1619
1620 #[test]
1621 fn a_rational_curves_weights_survive_the_move() {
1622 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
1629 let shift = GeneralTransform::new(Matrix3::IDENTITY, Vector::new(10.0, -4.0, 6.0));
1630 let moved = circle.general_transformed(&shift, T).unwrap();
1631
1632 let centre = Point::new(10.0, -4.0, 6.0);
1633 let (a, b) = moved.knots().domain();
1634 for i in 0..=300 {
1635 #[allow(clippy::cast_precision_loss)]
1636 let u = a + (b - a) * i as f64 / 300.0;
1637 let p = moved.point_at(u, T).unwrap();
1638 assert!(
1639 (p.distance(centre) - 2.0).abs() < 1e-12,
1640 "at {u} the radius is {}",
1641 p.distance(centre)
1642 );
1643 }
1644 }
1645
1646 #[test]
1647 fn a_transform_that_collapses_the_curve_is_refused() {
1648 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1652 let squash = GeneralTransform::new(Matrix3::new([[0.0; 3]; 3]), Vector::ZERO);
1653 assert!(circle.general_transformed(&squash, T).is_err());
1654 }
1655}