1use smallvec::SmallVec;
17
18pub type DerivativeGrid<P> = SmallVec<[SmallVec<[P; 4]>; 4]>;
22use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
23
24use crate::{KnotVector, Point, Point2, Vector, Vector2};
25
26pub trait Blend: Copy {
33 fn zero() -> Self;
35 fn scale(self, k: f64) -> Self;
37 fn add(self, other: Self) -> Self;
39
40 #[must_use]
42 fn lerp(self, other: Self, t: f64) -> Self {
43 self.scale(1.0 - t).add(other.scale(t))
44 }
45
46 #[must_use]
48 fn sub(self, other: Self) -> Self {
49 self.add(other.scale(-1.0))
50 }
51}
52
53impl Blend for f64 {
54 fn zero() -> Self {
55 0.0
56 }
57 fn scale(self, k: f64) -> Self {
58 self * k
59 }
60 fn add(self, other: Self) -> Self {
61 self + other
62 }
63}
64
65impl Blend for Vector {
66 fn zero() -> Self {
67 Self::ZERO
68 }
69 fn scale(self, k: f64) -> Self {
70 self * k
71 }
72 fn add(self, other: Self) -> Self {
73 self + other
74 }
75}
76
77impl Blend for Vector2 {
78 fn zero() -> Self {
79 Self::ZERO
80 }
81 fn scale(self, k: f64) -> Self {
82 self * k
83 }
84 fn add(self, other: Self) -> Self {
85 self + other
86 }
87}
88
89impl Blend for Point {
90 fn zero() -> Self {
91 Self::ORIGIN
92 }
93 fn scale(self, k: f64) -> Self {
94 Self::from_vector(self.to_vector() * k)
95 }
96 fn add(self, other: Self) -> Self {
97 Self::from_vector(self.to_vector() + other.to_vector())
98 }
99}
100
101impl Blend for Point2 {
102 fn zero() -> Self {
103 Self::ORIGIN
104 }
105 fn scale(self, k: f64) -> Self {
106 Self::from_vector(self.to_vector() * k)
107 }
108 fn add(self, other: Self) -> Self {
109 Self::from_vector(self.to_vector() + other.to_vector())
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct Weighted<P> {
120 pub scaled: P,
122 pub weight: f64,
124}
125
126impl<P: Blend> Weighted<P> {
127 pub fn new(point: P, weight: f64, tol: Tolerances) -> OgeomResult<Self> {
136 if !weight.is_finite() || weight <= tol.confusion() {
137 ogeom_bail!(
138 Construction,
139 "control point weight {weight} must be finite and positive"
140 );
141 }
142 Ok(Self {
143 scaled: point.scale(weight),
144 weight,
145 })
146 }
147
148 #[must_use]
150 pub fn point(self) -> P {
151 self.scaled.scale(1.0 / self.weight)
152 }
153}
154
155impl<P: Blend> Blend for Weighted<P> {
156 fn zero() -> Self {
157 Self {
158 scaled: P::zero(),
159 weight: 0.0,
160 }
161 }
162 fn scale(self, k: f64) -> Self {
163 Self {
164 scaled: self.scaled.scale(k),
165 weight: self.weight * k,
166 }
167 }
168 fn add(self, other: Self) -> Self {
169 Self {
170 scaled: self.scaled.add(other.scaled),
171 weight: self.weight + other.weight,
172 }
173 }
174}
175
176fn check_shape<P>(knots: &KnotVector, control: &[P]) -> OgeomResult<()> {
178 if control.len() != knots.control_point_count() {
179 ogeom_bail!(
180 Dimension,
181 "knot vector describes {} control points, got {}",
182 knots.control_point_count(),
183 control.len()
184 );
185 }
186 Ok(())
187}
188
189pub fn evaluate<P: Blend>(
203 knots: &KnotVector,
204 control: &[P],
205 u: f64,
206 tol: Tolerances,
207) -> OgeomResult<P> {
208 check_shape(knots, control)?;
209 let span = knots.span(u, tol)?;
210 let p = knots.degree();
211
212 let mut d: Vec<P> = (0..=p).map(|i| control[span - p + i]).collect();
213 let k = knots.knots();
214 for r in 1..=p {
215 for j in (r..=p).rev() {
216 let left = k[span + j - p];
217 let right = k[span + j + 1 - r];
218 let alpha = (u - left) / (right - left);
222 d[j] = d[j - 1].lerp(d[j], alpha);
223 }
224 }
225 Ok(d[p])
226}
227
228pub fn derivatives<P: Blend>(
237 knots: &KnotVector,
238 control: &[P],
239 u: f64,
240 n: usize,
241 tol: Tolerances,
242) -> OgeomResult<Vec<P>> {
243 check_shape(knots, control)?;
244 let span = knots.span(u, tol)?;
245 let p = knots.degree();
246 let basis = knots.basis_derivatives(span, u, n);
247
248 Ok((0..=n)
249 .map(|order| {
250 let mut sum = P::zero();
251 for i in 0..=p {
252 sum = sum.add(control[span - p + i].scale(basis[order][i]));
253 }
254 sum
255 })
256 .collect())
257}
258
259pub fn insert_knot<P: Blend>(
273 knots: &KnotVector,
274 control: &[P],
275 value: f64,
276 count: usize,
277 tol: Tolerances,
278) -> OgeomResult<Spline<P>> {
279 check_shape(knots, control)?;
280 if count == 0 {
281 return Ok((knots.clone(), control.to_vec()));
282 }
283 let span = knots.span(value, tol)?;
284 let p = knots.degree();
285 let existing = knots.multiplicity_of(value);
286 if existing + count > p {
287 ogeom_bail!(
288 Construction,
289 "inserting {count} copies of {value} would reach multiplicity {}, above degree {p}",
290 existing + count
291 );
292 }
293
294 let new_knots = knots.with_knot_inserted(value, count)?;
295 let last = control.len() - 1;
296 let k = knots.knots();
297 let (s, r) = (existing, count);
298
299 let mut points: Vec<P> = vec![P::zero(); control.len() + r];
300 points[..=span - p].copy_from_slice(&control[..=span - p]);
303 points[span - s + r..=last + r].copy_from_slice(&control[span - s..=last]);
304
305 let mut window: Vec<P> = (0..=p - s).map(|i| control[span - p + i]).collect();
308 let mut window_start = span - p;
309 for j in 1..=r {
310 window_start = span - p + j;
311 for i in 0..=p - j - s {
312 let left = k[window_start + i];
313 let right = k[i + span + 1];
314 let alpha = (value - left) / (right - left);
315 window[i] = window[i].lerp(window[i + 1], alpha);
316 }
317 points[window_start] = window[0];
318 points[span + r - j - s] = window[p - j - s];
319 }
320
321 if window_start + 1 < span - s {
323 let width = (span - s) - (window_start + 1);
324 points[window_start + 1..span - s].copy_from_slice(&window[1..=width]);
325 }
326
327 Ok((new_knots, points))
328}
329
330pub type Spline<P> = (KnotVector, Vec<P>);
332
333pub type BezierSegment<P> = ((f64, f64), Vec<P>);
335
336pub fn join<P: Blend>(a: &Spline<P>, b: &Spline<P>) -> OgeomResult<Spline<P>> {
351 let ((ak, ac), (bk, bc)) = (a, b);
352 check_shape(ak, ac)?;
353 check_shape(bk, bc)?;
354 let p = ak.degree();
355 if bk.degree() != p {
356 ogeom_bail!(
357 Construction,
358 "cannot join a degree {p} B-spline to a degree {} one",
359 bk.degree()
360 );
361 }
362 if !ak.is_clamped() || !bk.is_clamped() {
363 ogeom_bail!(Construction, "only clamped B-splines join");
364 }
365 let shift = ak.domain_end() - bk.domain_start();
366 let mut knots: Vec<f64> = ak.knots()[..ak.knots().len() - 1].to_vec();
367 knots.extend(bk.knots()[p + 1..].iter().map(|k| k + shift));
368 let mut control: Vec<P> = ac[..ac.len() - 1].to_vec();
369 control.extend_from_slice(bc);
370 Ok((KnotVector::new(knots, p)?, control))
371}
372
373pub fn extend<P: Blend>(
395 knots: &KnotVector,
396 control: &[P],
397 at_end: bool,
398 span: f64,
399 continuity: usize,
400 tol: Tolerances,
401) -> OgeomResult<Spline<P>> {
402 check_shape(knots, control)?;
403 if !knots.is_clamped() {
404 ogeom_bail!(Construction, "only clamped B-splines extend");
405 }
406 if !(span > 0.0 && span.is_finite()) {
407 ogeom_bail!(
408 Construction,
409 "an extension needs a positive, finite span; got {span}"
410 );
411 }
412 if !at_end {
413 let (rk, rc) = reverse(knots, control);
417 let (ek, ec) = extend(&rk, &rc, true, span, continuity, tol)?;
418 let (bk, bc) = reverse(&ek, &ec);
419 let (lo, hi) = knots.domain();
420 return Ok((bk.reparameterized(lo - span, hi)?, bc));
421 }
422 let p = knots.degree();
423 let k = continuity.min(p);
424 let end = knots.domain_end();
425 let jet = derivatives(knots, control, end, k, tol)?;
426 let mut bezier: Vec<P> = Vec::with_capacity(k + 1);
429 for j in 0..=k {
430 let mut b = P::zero();
431 let (mut factorial, mut power) = (1.0_f64, 1.0_f64);
432 for (i, derivative) in jet.iter().enumerate().take(j + 1) {
433 if i > 0 {
434 #[allow(clippy::cast_precision_loss)]
435 {
436 factorial *= i as f64;
437 }
438 power *= span;
439 }
440 #[allow(clippy::cast_precision_loss)]
441 let ratio = binomial_coefficient(j, i) as f64 / binomial_coefficient(k, i) as f64;
442 b = b.add(derivative.scale(ratio * power / factorial));
443 }
444 bezier.push(b);
445 }
446 let mut piece_knots: Vec<f64> = Vec::with_capacity(2 * (k + 1));
447 piece_knots.extend(core::iter::repeat_n(end, k + 1));
448 piece_knots.extend(core::iter::repeat_n(end + span, k + 1));
449 let mut piece: Spline<P> = (KnotVector::new(piece_knots, k)?, bezier);
450 for _ in k..p {
451 piece = elevate_degree(&piece.0, &piece.1, tol)?;
452 }
453 join(&(knots.clone(), control.to_vec()), &piece)
454}
455
456pub fn split<P: Blend>(
466 knots: &KnotVector,
467 control: &[P],
468 u: f64,
469 tol: Tolerances,
470) -> OgeomResult<(Spline<P>, Spline<P>)> {
471 check_shape(knots, control)?;
472 let (start, end) = knots.domain();
473 if u <= start + tol.parametric() || u >= end - tol.parametric() {
474 ogeom_bail!(
475 Domain,
476 "cannot split at {u}, an end of the domain [{start}, {end}]"
477 );
478 }
479 let p = knots.degree();
480 let existing = knots.multiplicity_of(u);
481 let (refined, points) = insert_knot(knots, control, u, p - existing, tol)?;
482
483 let cut = refined.knots().partition_point(|k| *k < u);
485 let left_points = points[..cut].to_vec();
486 let right_points = points[cut - 1..].to_vec();
487
488 let mut left_knots = refined.knots()[..cut + p].to_vec();
489 left_knots.push(u);
490 let mut right_knots = vec![u];
491 right_knots.extend_from_slice(&refined.knots()[cut..]);
492
493 Ok((
494 (KnotVector::new(left_knots, p)?, left_points),
495 (KnotVector::new(right_knots, p)?, right_points),
496 ))
497}
498
499pub fn to_bezier_segments<P: Blend>(
510 knots: &KnotVector,
511 control: &[P],
512 tol: Tolerances,
513) -> OgeomResult<Vec<BezierSegment<P>>> {
514 check_shape(knots, control)?;
515 let p = knots.degree();
516 let (start, end) = knots.domain();
517
518 let mut current_knots = knots.clone();
521 let mut current_points = control.to_vec();
522 for (value, multiplicity) in knots.distinct() {
523 if value <= start || value >= end {
524 continue;
525 }
526 let needed = p - multiplicity;
527 if needed > 0 {
528 let (k, c) = insert_knot(¤t_knots, ¤t_points, value, needed, tol)?;
529 current_knots = k;
530 current_points = c;
531 }
532 }
533
534 let breaks: Vec<f64> = core::iter::once(start)
535 .chain(
536 current_knots
537 .distinct()
538 .into_iter()
539 .filter(|(v, _)| *v > start && *v < end)
540 .map(|(v, _)| v),
541 )
542 .chain(core::iter::once(end))
543 .collect();
544
545 Ok(breaks
546 .windows(2)
547 .enumerate()
548 .map(|(i, w)| ((w[0], w[1]), current_points[i * p..i * p + p + 1].to_vec()))
549 .collect())
550}
551
552pub fn elevate_degree<P: Blend>(
562 knots: &KnotVector,
563 control: &[P],
564 tol: Tolerances,
565) -> OgeomResult<Spline<P>> {
566 check_shape(knots, control)?;
567 let p = knots.degree();
568 let segments = to_bezier_segments(knots, control, tol)?;
569
570 let mut points: Vec<P> = Vec::with_capacity(segments.len() * (p + 1) + 1);
571 let mut new_knots: Vec<f64> = Vec::new();
572
573 for (index, ((a, b), segment)) in segments.iter().enumerate() {
574 let mut elevated: Vec<P> = Vec::with_capacity(p + 2);
576 elevated.push(segment[0]);
577 #[allow(clippy::cast_precision_loss)]
578 for i in 1..=p {
579 let t = i as f64 / (p + 1) as f64;
580 elevated.push(segment[i - 1].lerp(segment[i], 1.0 - t));
581 }
582 elevated.push(segment[p]);
583
584 if index == 0 {
585 points.extend_from_slice(&elevated);
586 new_knots.extend(core::iter::repeat_n(*a, p + 2));
587 } else {
588 points.extend_from_slice(&elevated[1..]);
590 new_knots.extend(core::iter::repeat_n(*a, p + 1));
591 }
592 if index == segments.len() - 1 {
593 new_knots.extend(core::iter::repeat_n(*b, p + 2));
594 }
595 }
596
597 Ok((KnotVector::new(new_knots, p + 1)?, points))
598}
599
600#[must_use]
602pub fn reverse<P: Blend>(knots: &KnotVector, control: &[P]) -> Spline<P> {
603 let mut points = control.to_vec();
604 points.reverse();
605 (knots.reversed(), points)
606}
607
608pub fn evaluate_rational<P: Blend>(
616 knots: &KnotVector,
617 control: &[Weighted<P>],
618 u: f64,
619 tol: Tolerances,
620) -> OgeomResult<P> {
621 let h = evaluate(knots, control, u, tol)?;
622 if h.weight.abs() <= tol.confusion() {
623 ogeom_bail!(Numeric, "rational evaluation produced a vanishing weight");
624 }
625 Ok(h.point())
626}
627
628pub fn rational_derivatives<P: Blend>(
638 knots: &KnotVector,
639 control: &[Weighted<P>],
640 u: f64,
641 n: usize,
642 tol: Tolerances,
643) -> OgeomResult<Vec<P>> {
644 let homogeneous = derivatives(knots, control, u, n, tol)?;
645 if homogeneous[0].weight.abs() <= tol.confusion() {
646 ogeom_bail!(Numeric, "rational evaluation produced a vanishing weight");
647 }
648
649 let mut out: Vec<P> = Vec::with_capacity(n + 1);
651 for (order, term) in homogeneous.iter().enumerate() {
652 let mut value = term.scaled;
653 for i in 1..=order {
654 #[allow(clippy::cast_precision_loss)]
655 let binomial = binomial_coefficient(order, i) as f64;
656 value = value.sub(out[order - i].scale(binomial * homogeneous[i].weight));
657 }
658 out.push(value.scale(1.0 / homogeneous[0].weight));
659 }
660 Ok(out)
661}
662
663#[must_use]
666pub fn binomial_coefficient(n: usize, k: usize) -> u64 {
667 if k > n {
668 return 0;
669 }
670 let k = k.min(n - k);
671 let mut result = 1_u64;
672 for i in 0..k {
673 result = result * (n - i) as u64 / (i as u64 + 1);
674 }
675 result
676}
677
678#[cfg(test)]
679#[allow(clippy::unwrap_used)]
680mod join_tests {
681 use super::*;
682 use crate::Point;
683
684 #[test]
685 fn a_joined_spline_evaluates_as_its_two_halves_did() {
686 let tol = Tolerances::millimetres();
687 let control: Vec<Point> = (0..6)
688 .map(|i| Point::new(f64::from(i), f64::from(i * i % 5), 0.0))
689 .collect();
690 let knots = KnotVector::clamped_uniform(3, control.len()).unwrap();
691 let ((lk, lc), (rk, rc)) = split(&knots, &control, 0.4, tol).unwrap();
692 let (jk, jc) = join(&(lk, lc), &(rk, rc)).unwrap();
693 assert_eq!(
694 jk.domain(),
695 knots.domain(),
696 "the domain is the two laid end to end"
697 );
698 assert_eq!(
699 jc.len() + 3 + 1,
700 jk.knots().len(),
701 "the knots fit the controls"
702 );
703 for i in 0..=20 {
704 let u = f64::from(i) / 20.0;
705 let before = evaluate(&knots, &control, u, tol).unwrap();
706 let after = evaluate(&jk, &jc, u, tol).unwrap();
707 assert!(
708 before.is_equal(after, tol),
709 "at {u}: {before:?} became {after:?}"
710 );
711 }
712 }
713}
714
715#[cfg(test)]
716#[allow(clippy::unwrap_used)]
717mod tests {
718 use super::*;
719 use approx::assert_relative_eq;
720
721 const T: Tolerances = Tolerances::millimetres();
722
723 #[test]
727 fn an_extension_continues_the_curve_to_its_order() {
728 let knots = KnotVector::clamped_uniform(3, 6).unwrap();
729 let control = vec![
730 Point::new(0.0, 0.0, 0.0),
731 Point::new(1.0, 2.0, 0.5),
732 Point::new(2.5, 1.0, -0.5),
733 Point::new(4.0, 3.0, 1.0),
734 Point::new(5.0, 0.5, 0.0),
735 Point::new(6.0, 2.0, 2.0),
736 ];
737 let (lo, hi) = knots.domain();
738 for at_end in [true, false] {
739 let (ek, ec) = extend(&knots, &control, at_end, 0.4, 2, T).unwrap();
740 let (elo, ehi) = ek.domain();
741 if at_end {
742 assert!((elo - lo).abs() < 1e-12 && (ehi - (hi + 0.4)).abs() < 1e-12);
743 } else {
744 assert!((elo - (lo - 0.4)).abs() < 1e-12 && (ehi - hi).abs() < 1e-12);
745 }
746 for i in 0..=10 {
747 let u = lo + (hi - lo) * f64::from(i) / 10.0;
748 let was = evaluate(&knots, &control, u, T).unwrap();
749 let now = evaluate(&ek, &ec, u, T).unwrap();
750 assert!(
751 was.distance(now) < 1e-9,
752 "the original run at {u}: {was:?} vs {now:?}"
753 );
754 }
755 let join_at = if at_end { hi } else { lo };
758 let step = if at_end { 1e-7 } else { -1e-7 };
759 let inside = derivatives(&knots, &control, join_at - step, 2, T).unwrap();
760 let outside = derivatives(&ek, &ec, join_at + step, 2, T).unwrap();
761 for order in 0..=2 {
762 let (a, b) = (inside[order], outside[order]);
763 let gap = a.to_vector().sub(b.to_vector()).magnitude();
764 let scale = a.to_vector().magnitude().max(1.0);
765 assert!(
766 gap < scale * 1e-4,
767 "order {order} across the join: {a:?} vs {b:?}"
768 );
769 }
770 }
771 }
772
773 fn cubic_curve() -> (KnotVector, Vec<Point>) {
774 let control = vec![
775 Point::new(0.0, 0.0, 0.0),
776 Point::new(1.0, 2.0, 0.0),
777 Point::new(3.0, 3.0, 1.0),
778 Point::new(5.0, 1.0, 2.0),
779 Point::new(6.0, -1.0, 1.0),
780 Point::new(8.0, 0.0, 0.0),
781 ];
782 (
783 KnotVector::clamped_uniform(3, control.len()).unwrap(),
784 control,
785 )
786 }
787
788 fn sample(knots: &KnotVector, control: &[Point], n: usize) -> Vec<Point> {
789 let (a, b) = knots.domain();
790 (0..=n)
791 .map(|i| {
792 #[allow(clippy::cast_precision_loss)]
793 let u = a + (b - a) * (i as f64 / n as f64);
794 evaluate(knots, control, u, T).unwrap()
795 })
796 .collect()
797 }
798
799 #[test]
800 fn a_clamped_curve_interpolates_its_end_points() {
801 let (k, c) = cubic_curve();
802 let (a, b) = k.domain();
803 assert!(evaluate(&k, &c, a, T).unwrap().is_equal(c[0], T));
804 assert!(evaluate(&k, &c, b, T).unwrap().is_equal(c[c.len() - 1], T));
805 }
806
807 #[test]
808 fn de_boor_agrees_with_the_basis_function_sum() {
809 let (k, c) = cubic_curve();
811 for i in 0..=50 {
812 let u = f64::from(i) / 50.0;
813 let span = k.span(u, T).unwrap();
814 let basis = k.basis(span, u);
815 let mut sum = Vector::ZERO;
816 for j in 0..=k.degree() {
817 sum += c[span - k.degree() + j].to_vector() * basis[j];
818 }
819 let de_boor = evaluate(&k, &c, u, T).unwrap();
820 assert!(de_boor.is_equal(Point::from_vector(sum), T), "at u = {u}");
821 }
822 }
823
824 #[test]
825 fn shape_mismatches_and_out_of_domain_parameters_are_refused() {
826 let (k, c) = cubic_curve();
827 assert!(
828 evaluate(&k, &c[..3], 0.5, T).is_err(),
829 "too few control points"
830 );
831 assert!(evaluate(&k, &c, -0.1, T).is_err());
832 assert!(evaluate(&k, &c, 1.1, T).is_err());
833 }
834
835 #[test]
836 fn derivatives_agree_with_finite_differences() {
837 let (k, c) = cubic_curve();
838 let h = 1e-6;
839 for i in 1..20 {
840 let u = f64::from(i) / 20.0;
841 let d = derivatives(&k, &c, u, 2, T).unwrap();
842 assert!(d[0].is_equal(evaluate(&k, &c, u, T).unwrap(), T));
843
844 let ahead = evaluate(&k, &c, u + h, T).unwrap();
845 let behind = evaluate(&k, &c, u - h, T).unwrap();
846 let numeric = (ahead - behind) * (1.0 / (2.0 * h));
847 assert!(
848 (d[1].to_vector() - numeric).magnitude() < 1e-5,
849 "first derivative disagrees at {u}"
850 );
851 }
852 }
853
854 #[test]
855 fn knot_insertion_does_not_move_the_curve() {
856 let (k, c) = cubic_curve();
857 let before = sample(&k, &c, 100);
858 for (value, count) in [(0.25, 1), (0.5, 2), (0.75, 3), (0.1, 1)] {
859 let (k2, c2) = insert_knot(&k, &c, value, count, T).unwrap();
860 assert_eq!(c2.len(), c.len() + count);
861 assert_eq!(k2.multiplicity_of(value), k.multiplicity_of(value) + count);
862 let after = sample(&k2, &c2, 100);
863 for (a, b) in before.iter().zip(&after) {
864 assert!(
865 a.is_equal(*b, T),
866 "inserting {count} at {value} moved the curve"
867 );
868 }
869 }
870 }
871
872 #[test]
873 fn repeated_insertion_matches_a_single_multiple_insertion() {
874 let (k, c) = cubic_curve();
875 let (ka, ca) = insert_knot(&k, &c, 0.4, 3, T).unwrap();
876
877 let (k1, c1) = insert_knot(&k, &c, 0.4, 1, T).unwrap();
878 let (k2, c2) = insert_knot(&k1, &c1, 0.4, 1, T).unwrap();
879 let (kb, cb) = insert_knot(&k2, &c2, 0.4, 1, T).unwrap();
880
881 assert_eq!(ka.knots(), kb.knots());
882 for (a, b) in ca.iter().zip(&cb) {
883 assert!(a.is_equal(*b, T));
884 }
885 }
886
887 #[test]
888 fn insertion_beyond_the_degree_is_refused() {
889 let (k, c) = cubic_curve();
890 assert!(insert_knot(&k, &c, 0.5, 4, T).is_err());
891 assert!(insert_knot(&k, &c, 0.5, 3, T).is_ok());
892 assert!(
893 insert_knot(&k, &c, 2.0, 1, T).is_err(),
894 "outside the domain"
895 );
896 }
897
898 #[test]
899 fn splitting_reproduces_both_halves_of_the_original() {
900 let (k, c) = cubic_curve();
901 let cut = 0.4;
902 let ((lk, lc), (rk, rc)) = split(&k, &c, cut, T).unwrap();
903
904 assert_relative_eq!(lk.domain().1, cut, epsilon = 1e-15);
905 assert_relative_eq!(rk.domain().0, cut, epsilon = 1e-15);
906 assert!(lk.is_clamped() && rk.is_clamped());
907
908 for i in 0..=40 {
909 let t = f64::from(i) / 40.0;
910 let left_u = lk.domain().0 + (cut - lk.domain().0) * t;
911 let right_u = cut + (rk.domain().1 - cut) * t;
912 assert!(
913 evaluate(&lk, &lc, left_u, T)
914 .unwrap()
915 .is_equal(evaluate(&k, &c, left_u, T).unwrap(), T),
916 "left half diverges at {left_u}"
917 );
918 assert!(
919 evaluate(&rk, &rc, right_u, T)
920 .unwrap()
921 .is_equal(evaluate(&k, &c, right_u, T).unwrap(), T),
922 "right half diverges at {right_u}"
923 );
924 }
925 }
926
927 #[test]
928 fn splitting_at_an_end_of_the_domain_is_refused() {
929 let (k, c) = cubic_curve();
930 assert!(split(&k, &c, 0.0, T).is_err());
931 assert!(split(&k, &c, 1.0, T).is_err());
932 }
933
934 #[test]
935 fn bezier_decomposition_covers_the_curve_exactly() {
936 let (k, c) = cubic_curve();
937 let segments = to_bezier_segments(&k, &c, T).unwrap();
938 assert_eq!(segments.len(), 3);
940 for (_, points) in &segments {
941 assert_eq!(points.len(), k.degree() + 1);
942 }
943
944 for ((a, b), points) in &segments {
947 let bezier = KnotVector::clamped_uniform(k.degree(), points.len())
948 .unwrap()
949 .reparameterized(*a, *b)
950 .unwrap();
951 for i in 0..=20 {
952 let u = a + (b - a) * (f64::from(i) / 20.0);
953 assert!(
954 evaluate(&bezier, points, u, T)
955 .unwrap()
956 .is_equal(evaluate(&k, &c, u, T).unwrap(), T),
957 "segment [{a}, {b}] diverges at {u}"
958 );
959 }
960 }
961 }
962
963 #[test]
964 fn degree_elevation_does_not_move_the_curve() {
965 let (k, c) = cubic_curve();
966 let before = sample(&k, &c, 100);
967 let (k2, c2) = elevate_degree(&k, &c, T).unwrap();
968 assert_eq!(k2.degree(), k.degree() + 1);
969 assert_eq!(k2.domain(), k.domain());
970
971 let after = sample(&k2, &c2, 100);
972 for (a, b) in before.iter().zip(&after) {
973 assert!(a.is_equal(*b, T), "elevation moved the curve");
974 }
975 }
976
977 #[test]
978 fn elevation_twice_is_still_the_same_curve() {
979 let (k, c) = cubic_curve();
980 let before = sample(&k, &c, 60);
981 let (k1, c1) = elevate_degree(&k, &c, T).unwrap();
982 let (k2, c2) = elevate_degree(&k1, &c1, T).unwrap();
983 assert_eq!(k2.degree(), 5);
984 for (a, b) in before.iter().zip(&sample(&k2, &c2, 60)) {
985 assert!(a.is_equal(*b, T));
986 }
987 }
988
989 #[test]
990 fn reversal_traverses_the_same_points_backwards() {
991 let (k, c) = cubic_curve();
992 let (rk, rc) = reverse(&k, &c);
993 let (a, b) = k.domain();
994 for i in 0..=40 {
995 let t = f64::from(i) / 40.0;
996 let forward = evaluate(&k, &c, a + (b - a) * t, T).unwrap();
997 let backward = evaluate(&rk, &rc, a + (b - a) * (1.0 - t), T).unwrap();
998 assert!(forward.is_equal(backward, T), "at t = {t}");
999 }
1000 }
1001
1002 fn quarter_circle() -> (KnotVector, Vec<Weighted<Point>>) {
1005 let w = core::f64::consts::FRAC_1_SQRT_2;
1006 let control = vec![
1007 Weighted::new(Point::new(1.0, 0.0, 0.0), 1.0, T).unwrap(),
1008 Weighted::new(Point::new(1.0, 1.0, 0.0), w, T).unwrap(),
1009 Weighted::new(Point::new(0.0, 1.0, 0.0), 1.0, T).unwrap(),
1010 ];
1011 (KnotVector::clamped_uniform(2, 3).unwrap(), control)
1012 }
1013
1014 #[test]
1015 fn a_rational_quadratic_traces_an_exact_circular_arc() {
1016 let (k, c) = quarter_circle();
1017 for i in 0..=100 {
1018 let u = f64::from(i) / 100.0;
1019 let p = evaluate_rational(&k, &c, u, T).unwrap();
1020 assert_relative_eq!(p.to_vector().magnitude(), 1.0, epsilon = 1e-14);
1023 assert_relative_eq!(p.z, 0.0, epsilon = 1e-15);
1024 }
1025 assert!(
1026 evaluate_rational(&k, &c, 0.0, T)
1027 .unwrap()
1028 .is_equal(Point::new(1.0, 0.0, 0.0), T)
1029 );
1030 assert!(
1031 evaluate_rational(&k, &c, 1.0, T)
1032 .unwrap()
1033 .is_equal(Point::new(0.0, 1.0, 0.0), T)
1034 );
1035 }
1036
1037 #[test]
1038 fn rational_derivatives_agree_with_finite_differences() {
1039 let (k, c) = quarter_circle();
1040 let h = 1e-6;
1041 for i in 1..20 {
1042 let u = f64::from(i) / 20.0;
1043 let d = rational_derivatives(&k, &c, u, 2, T).unwrap();
1044 assert!(d[0].is_equal(evaluate_rational(&k, &c, u, T).unwrap(), T));
1045
1046 let ahead = evaluate_rational(&k, &c, u + h, T).unwrap();
1047 let behind = evaluate_rational(&k, &c, u - h, T).unwrap();
1048 let numeric = (ahead - behind) * (1.0 / (2.0 * h));
1049 assert!(
1050 (d[1].to_vector() - numeric).magnitude() < 1e-5,
1051 "at u = {u}: {:?} vs {numeric:?}",
1052 d[1]
1053 );
1054 }
1055 }
1056
1057 #[test]
1058 fn the_tangent_of_a_circular_arc_is_perpendicular_to_its_radius() {
1059 let (k, c) = quarter_circle();
1060 for i in 0..=20 {
1061 let u = f64::from(i) / 20.0;
1062 let d = rational_derivatives(&k, &c, u, 1, T).unwrap();
1063 let radius = d[0].to_vector();
1064 let tangent = d[1].to_vector();
1065 assert!(
1066 radius.dot(tangent).abs() < 1e-12,
1067 "not perpendicular at {u}: {}",
1068 radius.dot(tangent)
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn knot_insertion_preserves_a_rational_curve_too() {
1075 let (k, c) = quarter_circle();
1076 let (k2, c2) = insert_knot(&k, &c, 0.5, 1, T).unwrap();
1077 for i in 0..=50 {
1078 let u = f64::from(i) / 50.0;
1079 let a = evaluate_rational(&k, &c, u, T).unwrap();
1080 let b = evaluate_rational(&k2, &c2, u, T).unwrap();
1081 assert!(a.is_equal(b, T), "at {u}");
1082 assert_relative_eq!(b.to_vector().magnitude(), 1.0, epsilon = 1e-14);
1083 }
1084 }
1085
1086 #[test]
1087 fn degenerate_weights_are_refused() {
1088 assert!(Weighted::new(Point::ORIGIN, 0.0, T).is_err());
1089 assert!(Weighted::new(Point::ORIGIN, -1.0, T).is_err());
1090 assert!(Weighted::new(Point::ORIGIN, f64::NAN, T).is_err());
1091 assert!(Weighted::new(Point::ORIGIN, f64::INFINITY, T).is_err());
1092 assert!(Weighted::new(Point::ORIGIN, 2.0, T).is_ok());
1093 }
1094
1095 #[test]
1096 fn weighted_round_trips_through_its_homogeneous_form() {
1097 let p = Point::new(3.0, -1.0, 2.0);
1098 let w = Weighted::new(p, 2.5, T).unwrap();
1099 assert!(w.point().is_equal(p, T));
1100 assert!(w.scaled.is_equal(Point::new(7.5, -2.5, 5.0), T));
1101 }
1102
1103 #[test]
1104 fn binomial_coefficients() {
1105 assert_eq!(binomial_coefficient(0, 0), 1);
1106 assert_eq!(binomial_coefficient(5, 0), 1);
1107 assert_eq!(binomial_coefficient(5, 5), 1);
1108 assert_eq!(binomial_coefficient(5, 2), 10);
1109 assert_eq!(binomial_coefficient(10, 5), 252);
1110 assert_eq!(binomial_coefficient(3, 4), 0);
1111 }
1112
1113 #[test]
1114 fn scalar_and_planar_control_points_work_too() {
1115 let k = KnotVector::clamped_uniform(2, 4).unwrap();
1118 let scalars = vec![0.0_f64, 1.0, 3.0, 2.0];
1119 assert_relative_eq!(evaluate(&k, &scalars, 0.0, T).unwrap(), 0.0);
1120 assert_relative_eq!(evaluate(&k, &scalars, 1.0, T).unwrap(), 2.0);
1121
1122 let planar = vec![
1123 Point2::new(0.0, 0.0),
1124 Point2::new(1.0, 2.0),
1125 Point2::new(3.0, 1.0),
1126 Point2::new(4.0, 0.0),
1127 ];
1128 assert!(
1129 evaluate(&k, &planar, 0.0, T)
1130 .unwrap()
1131 .is_equal(planar[0], T)
1132 );
1133 assert!(
1134 evaluate(&k, &planar, 1.0, T)
1135 .unwrap()
1136 .is_equal(planar[3], T)
1137 );
1138 }
1139}
1140
1141#[derive(Debug, Clone, PartialEq)]
1148pub struct ControlGrid<P> {
1149 points: Vec<P>,
1150 u_count: usize,
1151 v_count: usize,
1152}
1153
1154impl<P: Blend> ControlGrid<P> {
1155 pub fn new(points: Vec<P>, u_count: usize, v_count: usize) -> OgeomResult<Self> {
1162 if u_count == 0 || v_count == 0 {
1163 ogeom_bail!(Dimension, "control grid must be at least 1x1");
1164 }
1165 if points.len() != u_count * v_count {
1166 ogeom_bail!(
1167 Dimension,
1168 "a {u_count}x{v_count} grid needs {} points, got {}",
1169 u_count * v_count,
1170 points.len()
1171 );
1172 }
1173 Ok(Self {
1174 points,
1175 u_count,
1176 v_count,
1177 })
1178 }
1179
1180 #[must_use]
1182 pub const fn u_count(&self) -> usize {
1183 self.u_count
1184 }
1185
1186 #[must_use]
1188 pub const fn v_count(&self) -> usize {
1189 self.v_count
1190 }
1191
1192 #[must_use]
1194 pub fn get(&self, i: usize, j: usize) -> Option<P> {
1195 if i >= self.u_count || j >= self.v_count {
1196 return None;
1197 }
1198 self.points.get(i * self.v_count + j).copied()
1199 }
1200
1201 #[must_use]
1203 pub fn points(&self) -> &[P] {
1204 &self.points
1205 }
1206
1207 #[must_use]
1209 pub fn transposed(&self) -> Self {
1210 let mut points = Vec::with_capacity(self.points.len());
1211 for j in 0..self.v_count {
1212 for i in 0..self.u_count {
1213 points.push(self.points[i * self.v_count + j]);
1214 }
1215 }
1216 Self {
1217 points,
1218 u_count: self.v_count,
1219 v_count: self.u_count,
1220 }
1221 }
1222
1223 #[must_use]
1225 pub fn map<Q: Blend>(&self, f: impl Fn(P) -> Q) -> ControlGrid<Q> {
1226 ControlGrid {
1227 points: self.points.iter().map(|p| f(*p)).collect(),
1228 u_count: self.u_count,
1229 v_count: self.v_count,
1230 }
1231 }
1232}
1233
1234fn check_grid_shape<P>(ku: &KnotVector, kv: &KnotVector, grid: &ControlGrid<P>) -> OgeomResult<()> {
1236 if grid.u_count != ku.control_point_count() || grid.v_count != kv.control_point_count() {
1237 ogeom_bail!(
1238 Dimension,
1239 "knot vectors describe a {}x{} grid, got {}x{}",
1240 ku.control_point_count(),
1241 kv.control_point_count(),
1242 grid.u_count,
1243 grid.v_count
1244 );
1245 }
1246 Ok(())
1247}
1248
1249pub fn evaluate_surface<P: Blend>(
1261 ku: &KnotVector,
1262 kv: &KnotVector,
1263 grid: &ControlGrid<P>,
1264 u: f64,
1265 v: f64,
1266 tol: Tolerances,
1267) -> OgeomResult<P> {
1268 check_grid_shape(ku, kv, grid)?;
1269 let (p, q) = (ku.degree(), kv.degree());
1270 let (su, sv) = (ku.span(u, tol)?, kv.span(v, tol)?);
1271 let (nu, nv) = (ku.basis(su, u), kv.basis(sv, v));
1272
1273 let mut total = P::zero();
1274 for (i, &weight_u) in nu.iter().enumerate() {
1275 let mut row = P::zero();
1278 for (j, &weight_v) in nv.iter().enumerate() {
1279 let Some(point) = grid.get(su - p + i, sv - q + j) else {
1280 ogeom_bail!(Dimension, "control grid index out of range");
1281 };
1282 row = row.add(point.scale(weight_v));
1283 }
1284 total = total.add(row.scale(weight_u));
1285 }
1286 Ok(total)
1287}
1288
1289pub fn surface_derivatives<P: Blend>(
1298 ku: &KnotVector,
1299 kv: &KnotVector,
1300 grid: &ControlGrid<P>,
1301 u: f64,
1302 v: f64,
1303 order: usize,
1304 tol: Tolerances,
1305) -> OgeomResult<DerivativeGrid<P>> {
1306 check_grid_shape(ku, kv, grid)?;
1307 let (p, q) = (ku.degree(), kv.degree());
1308 let (su, sv) = (ku.span(u, tol)?, kv.span(v, tol)?);
1309 let du = ku.basis_derivatives(su, u, order);
1310 let dv = kv.basis_derivatives(sv, v, order);
1311
1312 let mut out: DerivativeGrid<P> =
1313 core::iter::repeat_with(|| core::iter::repeat_with(P::zero).take(order + 1).collect())
1314 .take(order + 1)
1315 .collect();
1316 for (k, row) in out.iter_mut().enumerate() {
1317 for (l, cell) in row.iter_mut().enumerate() {
1318 let mut total = P::zero();
1322 for (i, &weight_u) in du[k].iter().enumerate() {
1323 let mut inner = P::zero();
1324 for (j, &weight_v) in dv[l].iter().enumerate() {
1325 let Some(point) = grid.get(su - p + i, sv - q + j) else {
1326 ogeom_bail!(Dimension, "control grid index out of range");
1327 };
1328 inner = inner.add(point.scale(weight_v));
1329 }
1330 total = total.add(inner.scale(weight_u));
1331 }
1332 *cell = total;
1333 }
1334 }
1335 Ok(out)
1336}
1337
1338pub fn evaluate_rational_surface<P: Blend>(
1347 ku: &KnotVector,
1348 kv: &KnotVector,
1349 grid: &ControlGrid<Weighted<P>>,
1350 u: f64,
1351 v: f64,
1352 tol: Tolerances,
1353) -> OgeomResult<P> {
1354 let h = evaluate_surface(ku, kv, grid, u, v, tol)?;
1355 if h.weight.abs() <= tol.confusion() {
1356 ogeom_bail!(
1357 Numeric,
1358 "rational surface evaluation produced a vanishing weight"
1359 );
1360 }
1361 Ok(h.point())
1362}
1363
1364pub fn rational_surface_derivatives<P: Blend>(
1377 ku: &KnotVector,
1378 kv: &KnotVector,
1379 grid: &ControlGrid<Weighted<P>>,
1380 u: f64,
1381 v: f64,
1382 order: usize,
1383 tol: Tolerances,
1384) -> OgeomResult<DerivativeGrid<P>> {
1385 let h = surface_derivatives(ku, kv, grid, u, v, order, tol)?;
1386 let w0 = h[0][0].weight;
1387 if w0.abs() <= tol.confusion() {
1388 ogeom_bail!(
1389 Numeric,
1390 "rational surface evaluation produced a vanishing weight"
1391 );
1392 }
1393
1394 let mut s: DerivativeGrid<P> =
1395 core::iter::repeat_with(|| core::iter::repeat_with(P::zero).take(order + 1).collect())
1396 .take(order + 1)
1397 .collect();
1398 for k in 0..=order {
1399 for l in 0..=order {
1400 let mut value = h[k][l].scaled;
1401 #[allow(clippy::cast_precision_loss)]
1402 for i in 1..=k {
1403 let c = binomial_coefficient(k, i) as f64;
1404 value = value.sub(s[k - i][l].scale(c * h[i][0].weight));
1405 }
1406 #[allow(clippy::cast_precision_loss)]
1407 for j in 1..=l {
1408 let c = binomial_coefficient(l, j) as f64;
1409 value = value.sub(s[k][l - j].scale(c * h[0][j].weight));
1410 }
1411 #[allow(clippy::cast_precision_loss)]
1412 for i in 1..=k {
1413 let ci = binomial_coefficient(k, i) as f64;
1414 for j in 1..=l {
1415 let cj = binomial_coefficient(l, j) as f64;
1416 value = value.sub(s[k - i][l - j].scale(ci * cj * h[i][j].weight));
1417 }
1418 }
1419 s[k][l] = value.scale(1.0 / w0);
1420 }
1421 }
1422 Ok(s)
1423}
1424
1425#[cfg(test)]
1426#[allow(clippy::unwrap_used)]
1427mod surface_tests {
1428 use super::*;
1429 use approx::assert_relative_eq;
1430
1431 const T: Tolerances = Tolerances::millimetres();
1432
1433 fn patch() -> (KnotVector, KnotVector, ControlGrid<Point>) {
1435 let (nu, nv) = (5, 4);
1436 let mut points = Vec::with_capacity(nu * nv);
1437 for i in 0..nu {
1438 for j in 0..nv {
1439 #[allow(clippy::cast_precision_loss)]
1440 let (x, y) = (i as f64, j as f64);
1441 points.push(Point::new(x, y, (x * 0.7).sin() * (y * 0.5).cos()));
1442 }
1443 }
1444 (
1445 KnotVector::clamped_uniform(3, nu).unwrap(),
1446 KnotVector::clamped_uniform(2, nv).unwrap(),
1447 ControlGrid::new(points, nu, nv).unwrap(),
1448 )
1449 }
1450
1451 #[test]
1452 fn grid_shape_is_checked_on_construction() {
1453 assert!(ControlGrid::new(vec![Point::ORIGIN; 6], 2, 3).is_ok());
1454 assert!(ControlGrid::new(vec![Point::ORIGIN; 6], 3, 3).is_err());
1455 assert!(ControlGrid::new(Vec::<Point>::new(), 0, 3).is_err());
1456 }
1457
1458 #[test]
1459 fn grid_indexing_is_row_major_and_bounds_checked() {
1460 let g = ControlGrid::new(
1461 vec![
1462 Point::new(0.0, 0.0, 0.0),
1463 Point::new(0.0, 1.0, 0.0),
1464 Point::new(0.0, 2.0, 0.0),
1465 Point::new(1.0, 0.0, 0.0),
1466 Point::new(1.0, 1.0, 0.0),
1467 Point::new(1.0, 2.0, 0.0),
1468 ],
1469 2,
1470 3,
1471 )
1472 .unwrap();
1473 assert_eq!(g.get(1, 2), Some(Point::new(1.0, 2.0, 0.0)));
1474 assert_eq!(g.get(0, 1), Some(Point::new(0.0, 1.0, 0.0)));
1475 assert_eq!(g.get(2, 0), None);
1476 assert_eq!(g.get(0, 3), None);
1477 }
1478
1479 #[test]
1480 fn transposing_twice_is_the_identity() {
1481 let (_, _, g) = patch();
1482 let t = g.transposed();
1483 assert_eq!(t.u_count(), g.v_count());
1484 assert_eq!(t.v_count(), g.u_count());
1485 for i in 0..g.u_count() {
1486 for j in 0..g.v_count() {
1487 assert_eq!(t.get(j, i), g.get(i, j));
1488 }
1489 }
1490 assert_eq!(t.transposed(), g);
1491 }
1492
1493 #[test]
1494 fn a_clamped_patch_interpolates_its_corner_control_points() {
1495 let (ku, kv, g) = patch();
1496 let ((u0, u1), (v0, v1)) = (ku.domain(), kv.domain());
1497 let corners = [
1498 (u0, v0, g.get(0, 0).unwrap()),
1499 (u0, v1, g.get(0, g.v_count() - 1).unwrap()),
1500 (u1, v0, g.get(g.u_count() - 1, 0).unwrap()),
1501 (u1, v1, g.get(g.u_count() - 1, g.v_count() - 1).unwrap()),
1502 ];
1503 for (u, v, expected) in corners {
1504 assert!(
1505 evaluate_surface(&ku, &kv, &g, u, v, T)
1506 .unwrap()
1507 .is_equal(expected, T),
1508 "corner ({u}, {v})"
1509 );
1510 }
1511 }
1512
1513 #[test]
1514 fn surface_shape_mismatches_are_refused() {
1515 let (ku, kv, g) = patch();
1516 let wrong = ControlGrid::new(g.points().to_vec(), 4, 5).unwrap();
1517 assert!(evaluate_surface(&ku, &kv, &wrong, 0.5, 0.5, T).is_err());
1518 assert!(evaluate_surface(&ku, &kv, &g, 1.5, 0.5, T).is_err());
1519 assert!(evaluate_surface(&ku, &kv, &g, 0.5, -0.5, T).is_err());
1520 }
1521
1522 #[test]
1523 fn surface_partials_agree_with_finite_differences() {
1524 let (ku, kv, g) = patch();
1525 let h = 1e-6;
1526 for iu in 1..6 {
1527 for iv in 1..6 {
1528 let (u, v) = (f64::from(iu) / 6.0, f64::from(iv) / 6.0);
1529 let d = surface_derivatives(&ku, &kv, &g, u, v, 2, T).unwrap();
1530 assert!(d[0][0].is_equal(evaluate_surface(&ku, &kv, &g, u, v, T).unwrap(), T));
1531
1532 let du = (evaluate_surface(&ku, &kv, &g, u + h, v, T).unwrap()
1533 - evaluate_surface(&ku, &kv, &g, u - h, v, T).unwrap())
1534 * (1.0 / (2.0 * h));
1535 let dv = (evaluate_surface(&ku, &kv, &g, u, v + h, T).unwrap()
1536 - evaluate_surface(&ku, &kv, &g, u, v - h, T).unwrap())
1537 * (1.0 / (2.0 * h));
1538 assert!((d[1][0].to_vector() - du).magnitude() < 1e-5 * du.magnitude().max(1.0));
1539 assert!((d[0][1].to_vector() - dv).magnitude() < 1e-5 * dv.magnitude().max(1.0));
1540
1541 let mixed = (evaluate_surface(&ku, &kv, &g, u + h, v + h, T).unwrap()
1543 - evaluate_surface(&ku, &kv, &g, u + h, v - h, T).unwrap()
1544 - (evaluate_surface(&ku, &kv, &g, u - h, v + h, T).unwrap()
1545 - evaluate_surface(&ku, &kv, &g, u - h, v - h, T).unwrap()))
1546 * (1.0 / (4.0 * h * h));
1547 assert!(
1548 (d[1][1].to_vector() - mixed).magnitude() < 1e-3 * mixed.magnitude().max(1.0),
1549 "mixed partial wrong at ({u}, {v})"
1550 );
1551 }
1552 }
1553 }
1554
1555 fn rational_hemisphere() -> (KnotVector, KnotVector, ControlGrid<Weighted<Point>>) {
1558 let w = core::f64::consts::FRAC_1_SQRT_2;
1559 let rows: [[(Point, f64); 3]; 3] = [
1561 [
1562 (Point::new(1.0, 0.0, 0.0), 1.0),
1563 (Point::new(1.0, 1.0, 0.0), w),
1564 (Point::new(0.0, 1.0, 0.0), 1.0),
1565 ],
1566 [
1567 (Point::new(1.0, 0.0, 1.0), w),
1568 (Point::new(1.0, 1.0, 1.0), w * w),
1569 (Point::new(0.0, 1.0, 1.0), w),
1570 ],
1571 [
1572 (Point::new(0.0, 0.0, 1.0), 1.0),
1573 (Point::new(0.0, 0.0, 1.0), w),
1574 (Point::new(0.0, 0.0, 1.0), 1.0),
1575 ],
1576 ];
1577 let points: Vec<_> = rows
1578 .iter()
1579 .flatten()
1580 .map(|(p, w)| Weighted::new(*p, *w, T).unwrap())
1581 .collect();
1582 (
1583 KnotVector::clamped_uniform(2, 3).unwrap(),
1584 KnotVector::clamped_uniform(2, 3).unwrap(),
1585 ControlGrid::new(points, 3, 3).unwrap(),
1586 )
1587 }
1588
1589 #[test]
1590 fn a_rational_biquadratic_traces_an_exact_sphere() {
1591 let (ku, kv, g) = rational_hemisphere();
1592 for iu in 0..=10 {
1593 for iv in 0..=10 {
1594 let (u, v) = (f64::from(iu) / 10.0, f64::from(iv) / 10.0);
1595 let p = evaluate_rational_surface(&ku, &kv, &g, u, v, T).unwrap();
1596 assert_relative_eq!(
1597 p.to_vector().magnitude(),
1598 1.0,
1599 epsilon = 1e-13,
1600 max_relative = 1e-13
1601 );
1602 }
1603 }
1604 }
1605
1606 #[test]
1607 fn rational_surface_partials_agree_with_finite_differences() {
1608 let (ku, kv, g) = rational_hemisphere();
1609 let h = 1e-6;
1610 let at = |u: f64, v: f64| evaluate_rational_surface(&ku, &kv, &g, u, v, T).unwrap();
1611 for iu in 1..6 {
1612 for iv in 1..6 {
1613 let (u, v) = (f64::from(iu) / 6.0, f64::from(iv) / 6.0);
1614 let d = rational_surface_derivatives(&ku, &kv, &g, u, v, 2, T).unwrap();
1615 assert!(d[0][0].is_equal(at(u, v), T));
1616
1617 let du = (at(u + h, v) - at(u - h, v)) * (1.0 / (2.0 * h));
1618 let dv = (at(u, v + h) - at(u, v - h)) * (1.0 / (2.0 * h));
1619 assert!(
1620 (d[1][0].to_vector() - du).magnitude() < 1e-5 * du.magnitude().max(1.0),
1621 "du wrong at ({u}, {v})"
1622 );
1623 assert!(
1624 (d[0][1].to_vector() - dv).magnitude() < 1e-5 * dv.magnitude().max(1.0),
1625 "dv wrong at ({u}, {v})"
1626 );
1627
1628 let mixed =
1631 (at(u + h, v + h) - at(u + h, v - h) - (at(u - h, v + h) - at(u - h, v - h)))
1632 * (1.0 / (4.0 * h * h));
1633 assert!(
1634 (d[1][1].to_vector() - mixed).magnitude() < 1e-2 * mixed.magnitude().max(1.0),
1635 "mixed partial wrong at ({u}, {v}): {:?} vs {mixed:?}",
1636 d[1][1]
1637 );
1638 }
1639 }
1640 }
1641
1642 #[test]
1643 fn a_spheres_normal_is_radial() {
1644 let (ku, kv, g) = rational_hemisphere();
1647 for iu in 1..8 {
1648 for iv in 1..8 {
1649 let (u, v) = (f64::from(iu) / 8.0, f64::from(iv) / 8.0);
1650 let d = rational_surface_derivatives(&ku, &kv, &g, u, v, 1, T).unwrap();
1651 let radius = d[0][0].to_vector();
1652 let normal = d[1][0].to_vector().cross(d[0][1].to_vector());
1653 assert!(
1654 normal.magnitude() > 1e-6,
1655 "degenerate tangents at ({u}, {v})"
1656 );
1657 let sine =
1658 radius.cross(normal).magnitude() / (radius.magnitude() * normal.magnitude());
1659 assert!(sine < 1e-9, "normal not radial at ({u}, {v}): sine {sine}");
1660 }
1661 }
1662 }
1663
1664 #[test]
1665 fn uniform_weights_reduce_to_the_polynomial_surface() {
1666 let (ku, kv, g) = patch();
1667 let weighted = g.map(|p| Weighted {
1668 scaled: p.scale(2.0),
1669 weight: 2.0,
1670 });
1671 for iu in 0..=6 {
1672 for iv in 0..=6 {
1673 let (u, v) = (f64::from(iu) / 6.0, f64::from(iv) / 6.0);
1674 let plain = evaluate_surface(&ku, &kv, &g, u, v, T).unwrap();
1675 let rational = evaluate_rational_surface(&ku, &kv, &weighted, u, v, T).unwrap();
1676 assert!(plain.is_equal(rational, T));
1677 }
1678 }
1679 }
1680}