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 extend_to<P: Blend>(
466 knots: &KnotVector,
467 control: &[P],
468 at_end: bool,
469 target: P,
470 span: f64,
471 continuity: usize,
472 tol: Tolerances,
473) -> OgeomResult<Spline<P>> {
474 check_shape(knots, control)?;
475 if !knots.is_clamped() {
476 ogeom_bail!(Construction, "only clamped B-splines extend");
477 }
478 if !(span > 0.0 && span.is_finite()) {
479 ogeom_bail!(
480 Construction,
481 "an extension needs a positive, finite span; got {span}"
482 );
483 }
484 if !at_end {
485 let (rk, rc) = reverse(knots, control);
486 let (ek, ec) = extend_to(&rk, &rc, true, target, span, continuity, tol)?;
487 let (bk, bc) = reverse(&ek, &ec);
488 let (lo, hi) = knots.domain();
489 return Ok((bk.reparameterized(lo - span, hi)?, bc));
490 }
491 let mut base: Spline<P> = (knots.clone(), control.to_vec());
492 let k = continuity.min(base.0.degree());
493 let n = k + 1;
494 while base.0.degree() < n {
495 base = elevate_degree(&base.0, &base.1, tol)?;
496 }
497 let end = base.0.domain_end();
498 let jet = derivatives(&base.0, &base.1, end, k, tol)?;
499 let mut bezier: Vec<P> = Vec::with_capacity(n + 1);
502 for j in 0..=k {
503 let mut b = P::zero();
504 let (mut factorial, mut power) = (1.0_f64, 1.0_f64);
505 for (i, derivative) in jet.iter().enumerate().take(j + 1) {
506 if i > 0 {
507 #[allow(clippy::cast_precision_loss)]
508 {
509 factorial *= i as f64;
510 }
511 power *= span;
512 }
513 #[allow(clippy::cast_precision_loss)]
514 let ratio = binomial_coefficient(j, i) as f64 / binomial_coefficient(n, i) as f64;
515 b = b.add(derivative.scale(ratio * power / factorial));
516 }
517 bezier.push(b);
518 }
519 bezier.push(target);
520 let mut piece_knots: Vec<f64> = Vec::with_capacity(2 * (n + 1));
521 piece_knots.extend(core::iter::repeat_n(end, n + 1));
522 piece_knots.extend(core::iter::repeat_n(end + span, n + 1));
523 let mut piece: Spline<P> = (KnotVector::new(piece_knots, n)?, bezier);
524 for _ in n..base.0.degree() {
525 piece = elevate_degree(&piece.0, &piece.1, tol)?;
526 }
527 join(&base, &piece)
528}
529
530pub fn split<P: Blend>(
540 knots: &KnotVector,
541 control: &[P],
542 u: f64,
543 tol: Tolerances,
544) -> OgeomResult<(Spline<P>, Spline<P>)> {
545 check_shape(knots, control)?;
546 let (start, end) = knots.domain();
547 if u <= start + tol.parametric() || u >= end - tol.parametric() {
548 ogeom_bail!(
549 Domain,
550 "cannot split at {u}, an end of the domain [{start}, {end}]"
551 );
552 }
553 let p = knots.degree();
554 let existing = knots.multiplicity_of(u);
555 let (refined, points) = insert_knot(knots, control, u, p - existing, tol)?;
556
557 let cut = refined.knots().partition_point(|k| *k < u);
559 let left_points = points[..cut].to_vec();
560 let right_points = points[cut - 1..].to_vec();
561
562 let mut left_knots = refined.knots()[..cut + p].to_vec();
563 left_knots.push(u);
564 let mut right_knots = vec![u];
565 right_knots.extend_from_slice(&refined.knots()[cut..]);
566
567 Ok((
568 (KnotVector::new(left_knots, p)?, left_points),
569 (KnotVector::new(right_knots, p)?, right_points),
570 ))
571}
572
573pub fn to_bezier_segments<P: Blend>(
584 knots: &KnotVector,
585 control: &[P],
586 tol: Tolerances,
587) -> OgeomResult<Vec<BezierSegment<P>>> {
588 check_shape(knots, control)?;
589 let p = knots.degree();
590 let (start, end) = knots.domain();
591
592 let mut current_knots = knots.clone();
595 let mut current_points = control.to_vec();
596 for (value, multiplicity) in knots.distinct() {
597 if value <= start || value >= end {
598 continue;
599 }
600 let needed = p - multiplicity;
601 if needed > 0 {
602 let (k, c) = insert_knot(¤t_knots, ¤t_points, value, needed, tol)?;
603 current_knots = k;
604 current_points = c;
605 }
606 }
607
608 let breaks: Vec<f64> = core::iter::once(start)
609 .chain(
610 current_knots
611 .distinct()
612 .into_iter()
613 .filter(|(v, _)| *v > start && *v < end)
614 .map(|(v, _)| v),
615 )
616 .chain(core::iter::once(end))
617 .collect();
618
619 Ok(breaks
620 .windows(2)
621 .enumerate()
622 .map(|(i, w)| ((w[0], w[1]), current_points[i * p..i * p + p + 1].to_vec()))
623 .collect())
624}
625
626pub fn elevate_degree<P: Blend>(
636 knots: &KnotVector,
637 control: &[P],
638 tol: Tolerances,
639) -> OgeomResult<Spline<P>> {
640 check_shape(knots, control)?;
641 let p = knots.degree();
642 let segments = to_bezier_segments(knots, control, tol)?;
643
644 let mut points: Vec<P> = Vec::with_capacity(segments.len() * (p + 1) + 1);
645 let mut new_knots: Vec<f64> = Vec::new();
646
647 for (index, ((a, b), segment)) in segments.iter().enumerate() {
648 let mut elevated: Vec<P> = Vec::with_capacity(p + 2);
650 elevated.push(segment[0]);
651 #[allow(clippy::cast_precision_loss)]
652 for i in 1..=p {
653 let t = i as f64 / (p + 1) as f64;
654 elevated.push(segment[i - 1].lerp(segment[i], 1.0 - t));
655 }
656 elevated.push(segment[p]);
657
658 if index == 0 {
659 points.extend_from_slice(&elevated);
660 new_knots.extend(core::iter::repeat_n(*a, p + 2));
661 } else {
662 points.extend_from_slice(&elevated[1..]);
664 new_knots.extend(core::iter::repeat_n(*a, p + 1));
665 }
666 if index == segments.len() - 1 {
667 new_knots.extend(core::iter::repeat_n(*b, p + 2));
668 }
669 }
670
671 Ok((KnotVector::new(new_knots, p + 1)?, points))
672}
673
674#[must_use]
676pub fn reverse<P: Blend>(knots: &KnotVector, control: &[P]) -> Spline<P> {
677 let mut points = control.to_vec();
678 points.reverse();
679 (knots.reversed(), points)
680}
681
682pub fn evaluate_rational<P: Blend>(
690 knots: &KnotVector,
691 control: &[Weighted<P>],
692 u: f64,
693 tol: Tolerances,
694) -> OgeomResult<P> {
695 let h = evaluate(knots, control, u, tol)?;
696 if h.weight.abs() <= tol.confusion() {
697 ogeom_bail!(Numeric, "rational evaluation produced a vanishing weight");
698 }
699 Ok(h.point())
700}
701
702pub fn rational_derivatives<P: Blend>(
712 knots: &KnotVector,
713 control: &[Weighted<P>],
714 u: f64,
715 n: usize,
716 tol: Tolerances,
717) -> OgeomResult<Vec<P>> {
718 let homogeneous = derivatives(knots, control, u, n, tol)?;
719 if homogeneous[0].weight.abs() <= tol.confusion() {
720 ogeom_bail!(Numeric, "rational evaluation produced a vanishing weight");
721 }
722
723 let mut out: Vec<P> = Vec::with_capacity(n + 1);
725 for (order, term) in homogeneous.iter().enumerate() {
726 let mut value = term.scaled;
727 for i in 1..=order {
728 #[allow(clippy::cast_precision_loss)]
729 let binomial = binomial_coefficient(order, i) as f64;
730 value = value.sub(out[order - i].scale(binomial * homogeneous[i].weight));
731 }
732 out.push(value.scale(1.0 / homogeneous[0].weight));
733 }
734 Ok(out)
735}
736
737#[must_use]
740pub fn binomial_coefficient(n: usize, k: usize) -> u64 {
741 if k > n {
742 return 0;
743 }
744 let k = k.min(n - k);
745 let mut result = 1_u64;
746 for i in 0..k {
747 result = result * (n - i) as u64 / (i as u64 + 1);
748 }
749 result
750}
751
752#[cfg(test)]
753#[allow(clippy::unwrap_used)]
754mod join_tests {
755 use super::*;
756 use crate::Point;
757
758 #[test]
759 fn a_joined_spline_evaluates_as_its_two_halves_did() {
760 let tol = Tolerances::millimetres();
761 let control: Vec<Point> = (0..6)
762 .map(|i| Point::new(f64::from(i), f64::from(i * i % 5), 0.0))
763 .collect();
764 let knots = KnotVector::clamped_uniform(3, control.len()).unwrap();
765 let ((lk, lc), (rk, rc)) = split(&knots, &control, 0.4, tol).unwrap();
766 let (jk, jc) = join(&(lk, lc), &(rk, rc)).unwrap();
767 assert_eq!(
768 jk.domain(),
769 knots.domain(),
770 "the domain is the two laid end to end"
771 );
772 assert_eq!(
773 jc.len() + 3 + 1,
774 jk.knots().len(),
775 "the knots fit the controls"
776 );
777 for i in 0..=20 {
778 let u = f64::from(i) / 20.0;
779 let before = evaluate(&knots, &control, u, tol).unwrap();
780 let after = evaluate(&jk, &jc, u, tol).unwrap();
781 assert!(
782 before.is_equal(after, tol),
783 "at {u}: {before:?} became {after:?}"
784 );
785 }
786 }
787}
788
789#[cfg(test)]
790#[allow(clippy::unwrap_used)]
791mod tests {
792 use super::*;
793 use approx::assert_relative_eq;
794
795 const T: Tolerances = Tolerances::millimetres();
796
797 #[test]
801 fn an_extension_continues_the_curve_to_its_order() {
802 let knots = KnotVector::clamped_uniform(3, 6).unwrap();
803 let control = vec![
804 Point::new(0.0, 0.0, 0.0),
805 Point::new(1.0, 2.0, 0.5),
806 Point::new(2.5, 1.0, -0.5),
807 Point::new(4.0, 3.0, 1.0),
808 Point::new(5.0, 0.5, 0.0),
809 Point::new(6.0, 2.0, 2.0),
810 ];
811 let (lo, hi) = knots.domain();
812 for at_end in [true, false] {
813 let (ek, ec) = extend(&knots, &control, at_end, 0.4, 2, T).unwrap();
814 let (elo, ehi) = ek.domain();
815 if at_end {
816 assert!((elo - lo).abs() < 1e-12 && (ehi - (hi + 0.4)).abs() < 1e-12);
817 } else {
818 assert!((elo - (lo - 0.4)).abs() < 1e-12 && (ehi - hi).abs() < 1e-12);
819 }
820 for i in 0..=10 {
821 let u = lo + (hi - lo) * f64::from(i) / 10.0;
822 let was = evaluate(&knots, &control, u, T).unwrap();
823 let now = evaluate(&ek, &ec, u, T).unwrap();
824 assert!(
825 was.distance(now) < 1e-9,
826 "the original run at {u}: {was:?} vs {now:?}"
827 );
828 }
829 let join_at = if at_end { hi } else { lo };
832 let step = if at_end { 1e-7 } else { -1e-7 };
833 let inside = derivatives(&knots, &control, join_at - step, 2, T).unwrap();
834 let outside = derivatives(&ek, &ec, join_at + step, 2, T).unwrap();
835 for order in 0..=2 {
836 let (a, b) = (inside[order], outside[order]);
837 let gap = a.to_vector().sub(b.to_vector()).magnitude();
838 let scale = a.to_vector().magnitude().max(1.0);
839 assert!(
840 gap < scale * 1e-4,
841 "order {order} across the join: {a:?} vs {b:?}"
842 );
843 }
844 }
845 }
846
847 fn cubic_curve() -> (KnotVector, Vec<Point>) {
848 let control = vec![
849 Point::new(0.0, 0.0, 0.0),
850 Point::new(1.0, 2.0, 0.0),
851 Point::new(3.0, 3.0, 1.0),
852 Point::new(5.0, 1.0, 2.0),
853 Point::new(6.0, -1.0, 1.0),
854 Point::new(8.0, 0.0, 0.0),
855 ];
856 (
857 KnotVector::clamped_uniform(3, control.len()).unwrap(),
858 control,
859 )
860 }
861
862 fn sample(knots: &KnotVector, control: &[Point], n: usize) -> Vec<Point> {
863 let (a, b) = knots.domain();
864 (0..=n)
865 .map(|i| {
866 #[allow(clippy::cast_precision_loss)]
867 let u = a + (b - a) * (i as f64 / n as f64);
868 evaluate(knots, control, u, T).unwrap()
869 })
870 .collect()
871 }
872
873 #[test]
874 fn a_clamped_curve_interpolates_its_end_points() {
875 let (k, c) = cubic_curve();
876 let (a, b) = k.domain();
877 assert!(evaluate(&k, &c, a, T).unwrap().is_equal(c[0], T));
878 assert!(evaluate(&k, &c, b, T).unwrap().is_equal(c[c.len() - 1], T));
879 }
880
881 #[test]
882 fn de_boor_agrees_with_the_basis_function_sum() {
883 let (k, c) = cubic_curve();
885 for i in 0..=50 {
886 let u = f64::from(i) / 50.0;
887 let span = k.span(u, T).unwrap();
888 let basis = k.basis(span, u);
889 let mut sum = Vector::ZERO;
890 for j in 0..=k.degree() {
891 sum += c[span - k.degree() + j].to_vector() * basis[j];
892 }
893 let de_boor = evaluate(&k, &c, u, T).unwrap();
894 assert!(de_boor.is_equal(Point::from_vector(sum), T), "at u = {u}");
895 }
896 }
897
898 #[test]
899 fn shape_mismatches_and_out_of_domain_parameters_are_refused() {
900 let (k, c) = cubic_curve();
901 assert!(
902 evaluate(&k, &c[..3], 0.5, T).is_err(),
903 "too few control points"
904 );
905 assert!(evaluate(&k, &c, -0.1, T).is_err());
906 assert!(evaluate(&k, &c, 1.1, T).is_err());
907 }
908
909 #[test]
910 fn derivatives_agree_with_finite_differences() {
911 let (k, c) = cubic_curve();
912 let h = 1e-6;
913 for i in 1..20 {
914 let u = f64::from(i) / 20.0;
915 let d = derivatives(&k, &c, u, 2, T).unwrap();
916 assert!(d[0].is_equal(evaluate(&k, &c, u, T).unwrap(), T));
917
918 let ahead = evaluate(&k, &c, u + h, T).unwrap();
919 let behind = evaluate(&k, &c, u - h, T).unwrap();
920 let numeric = (ahead - behind) * (1.0 / (2.0 * h));
921 assert!(
922 (d[1].to_vector() - numeric).magnitude() < 1e-5,
923 "first derivative disagrees at {u}"
924 );
925 }
926 }
927
928 #[test]
929 fn knot_insertion_does_not_move_the_curve() {
930 let (k, c) = cubic_curve();
931 let before = sample(&k, &c, 100);
932 for (value, count) in [(0.25, 1), (0.5, 2), (0.75, 3), (0.1, 1)] {
933 let (k2, c2) = insert_knot(&k, &c, value, count, T).unwrap();
934 assert_eq!(c2.len(), c.len() + count);
935 assert_eq!(k2.multiplicity_of(value), k.multiplicity_of(value) + count);
936 let after = sample(&k2, &c2, 100);
937 for (a, b) in before.iter().zip(&after) {
938 assert!(
939 a.is_equal(*b, T),
940 "inserting {count} at {value} moved the curve"
941 );
942 }
943 }
944 }
945
946 #[test]
947 fn repeated_insertion_matches_a_single_multiple_insertion() {
948 let (k, c) = cubic_curve();
949 let (ka, ca) = insert_knot(&k, &c, 0.4, 3, T).unwrap();
950
951 let (k1, c1) = insert_knot(&k, &c, 0.4, 1, T).unwrap();
952 let (k2, c2) = insert_knot(&k1, &c1, 0.4, 1, T).unwrap();
953 let (kb, cb) = insert_knot(&k2, &c2, 0.4, 1, T).unwrap();
954
955 assert_eq!(ka.knots(), kb.knots());
956 for (a, b) in ca.iter().zip(&cb) {
957 assert!(a.is_equal(*b, T));
958 }
959 }
960
961 #[test]
962 fn insertion_beyond_the_degree_is_refused() {
963 let (k, c) = cubic_curve();
964 assert!(insert_knot(&k, &c, 0.5, 4, T).is_err());
965 assert!(insert_knot(&k, &c, 0.5, 3, T).is_ok());
966 assert!(
967 insert_knot(&k, &c, 2.0, 1, T).is_err(),
968 "outside the domain"
969 );
970 }
971
972 #[test]
973 fn splitting_reproduces_both_halves_of_the_original() {
974 let (k, c) = cubic_curve();
975 let cut = 0.4;
976 let ((lk, lc), (rk, rc)) = split(&k, &c, cut, T).unwrap();
977
978 assert_relative_eq!(lk.domain().1, cut, epsilon = 1e-15);
979 assert_relative_eq!(rk.domain().0, cut, epsilon = 1e-15);
980 assert!(lk.is_clamped() && rk.is_clamped());
981
982 for i in 0..=40 {
983 let t = f64::from(i) / 40.0;
984 let left_u = lk.domain().0 + (cut - lk.domain().0) * t;
985 let right_u = cut + (rk.domain().1 - cut) * t;
986 assert!(
987 evaluate(&lk, &lc, left_u, T)
988 .unwrap()
989 .is_equal(evaluate(&k, &c, left_u, T).unwrap(), T),
990 "left half diverges at {left_u}"
991 );
992 assert!(
993 evaluate(&rk, &rc, right_u, T)
994 .unwrap()
995 .is_equal(evaluate(&k, &c, right_u, T).unwrap(), T),
996 "right half diverges at {right_u}"
997 );
998 }
999 }
1000
1001 #[test]
1002 fn splitting_at_an_end_of_the_domain_is_refused() {
1003 let (k, c) = cubic_curve();
1004 assert!(split(&k, &c, 0.0, T).is_err());
1005 assert!(split(&k, &c, 1.0, T).is_err());
1006 }
1007
1008 #[test]
1009 fn bezier_decomposition_covers_the_curve_exactly() {
1010 let (k, c) = cubic_curve();
1011 let segments = to_bezier_segments(&k, &c, T).unwrap();
1012 assert_eq!(segments.len(), 3);
1014 for (_, points) in &segments {
1015 assert_eq!(points.len(), k.degree() + 1);
1016 }
1017
1018 for ((a, b), points) in &segments {
1021 let bezier = KnotVector::clamped_uniform(k.degree(), points.len())
1022 .unwrap()
1023 .reparameterized(*a, *b)
1024 .unwrap();
1025 for i in 0..=20 {
1026 let u = a + (b - a) * (f64::from(i) / 20.0);
1027 assert!(
1028 evaluate(&bezier, points, u, T)
1029 .unwrap()
1030 .is_equal(evaluate(&k, &c, u, T).unwrap(), T),
1031 "segment [{a}, {b}] diverges at {u}"
1032 );
1033 }
1034 }
1035 }
1036
1037 #[test]
1038 fn degree_elevation_does_not_move_the_curve() {
1039 let (k, c) = cubic_curve();
1040 let before = sample(&k, &c, 100);
1041 let (k2, c2) = elevate_degree(&k, &c, T).unwrap();
1042 assert_eq!(k2.degree(), k.degree() + 1);
1043 assert_eq!(k2.domain(), k.domain());
1044
1045 let after = sample(&k2, &c2, 100);
1046 for (a, b) in before.iter().zip(&after) {
1047 assert!(a.is_equal(*b, T), "elevation moved the curve");
1048 }
1049 }
1050
1051 #[test]
1052 fn elevation_twice_is_still_the_same_curve() {
1053 let (k, c) = cubic_curve();
1054 let before = sample(&k, &c, 60);
1055 let (k1, c1) = elevate_degree(&k, &c, T).unwrap();
1056 let (k2, c2) = elevate_degree(&k1, &c1, T).unwrap();
1057 assert_eq!(k2.degree(), 5);
1058 for (a, b) in before.iter().zip(&sample(&k2, &c2, 60)) {
1059 assert!(a.is_equal(*b, T));
1060 }
1061 }
1062
1063 #[test]
1064 fn reversal_traverses_the_same_points_backwards() {
1065 let (k, c) = cubic_curve();
1066 let (rk, rc) = reverse(&k, &c);
1067 let (a, b) = k.domain();
1068 for i in 0..=40 {
1069 let t = f64::from(i) / 40.0;
1070 let forward = evaluate(&k, &c, a + (b - a) * t, T).unwrap();
1071 let backward = evaluate(&rk, &rc, a + (b - a) * (1.0 - t), T).unwrap();
1072 assert!(forward.is_equal(backward, T), "at t = {t}");
1073 }
1074 }
1075
1076 fn quarter_circle() -> (KnotVector, Vec<Weighted<Point>>) {
1079 let w = core::f64::consts::FRAC_1_SQRT_2;
1080 let control = vec![
1081 Weighted::new(Point::new(1.0, 0.0, 0.0), 1.0, T).unwrap(),
1082 Weighted::new(Point::new(1.0, 1.0, 0.0), w, T).unwrap(),
1083 Weighted::new(Point::new(0.0, 1.0, 0.0), 1.0, T).unwrap(),
1084 ];
1085 (KnotVector::clamped_uniform(2, 3).unwrap(), control)
1086 }
1087
1088 #[test]
1089 fn a_rational_quadratic_traces_an_exact_circular_arc() {
1090 let (k, c) = quarter_circle();
1091 for i in 0..=100 {
1092 let u = f64::from(i) / 100.0;
1093 let p = evaluate_rational(&k, &c, u, T).unwrap();
1094 assert_relative_eq!(p.to_vector().magnitude(), 1.0, epsilon = 1e-14);
1097 assert_relative_eq!(p.z, 0.0, epsilon = 1e-15);
1098 }
1099 assert!(
1100 evaluate_rational(&k, &c, 0.0, T)
1101 .unwrap()
1102 .is_equal(Point::new(1.0, 0.0, 0.0), T)
1103 );
1104 assert!(
1105 evaluate_rational(&k, &c, 1.0, T)
1106 .unwrap()
1107 .is_equal(Point::new(0.0, 1.0, 0.0), T)
1108 );
1109 }
1110
1111 #[test]
1112 fn rational_derivatives_agree_with_finite_differences() {
1113 let (k, c) = quarter_circle();
1114 let h = 1e-6;
1115 for i in 1..20 {
1116 let u = f64::from(i) / 20.0;
1117 let d = rational_derivatives(&k, &c, u, 2, T).unwrap();
1118 assert!(d[0].is_equal(evaluate_rational(&k, &c, u, T).unwrap(), T));
1119
1120 let ahead = evaluate_rational(&k, &c, u + h, T).unwrap();
1121 let behind = evaluate_rational(&k, &c, u - h, T).unwrap();
1122 let numeric = (ahead - behind) * (1.0 / (2.0 * h));
1123 assert!(
1124 (d[1].to_vector() - numeric).magnitude() < 1e-5,
1125 "at u = {u}: {:?} vs {numeric:?}",
1126 d[1]
1127 );
1128 }
1129 }
1130
1131 #[test]
1132 fn the_tangent_of_a_circular_arc_is_perpendicular_to_its_radius() {
1133 let (k, c) = quarter_circle();
1134 for i in 0..=20 {
1135 let u = f64::from(i) / 20.0;
1136 let d = rational_derivatives(&k, &c, u, 1, T).unwrap();
1137 let radius = d[0].to_vector();
1138 let tangent = d[1].to_vector();
1139 assert!(
1140 radius.dot(tangent).abs() < 1e-12,
1141 "not perpendicular at {u}: {}",
1142 radius.dot(tangent)
1143 );
1144 }
1145 }
1146
1147 #[test]
1148 fn knot_insertion_preserves_a_rational_curve_too() {
1149 let (k, c) = quarter_circle();
1150 let (k2, c2) = insert_knot(&k, &c, 0.5, 1, T).unwrap();
1151 for i in 0..=50 {
1152 let u = f64::from(i) / 50.0;
1153 let a = evaluate_rational(&k, &c, u, T).unwrap();
1154 let b = evaluate_rational(&k2, &c2, u, T).unwrap();
1155 assert!(a.is_equal(b, T), "at {u}");
1156 assert_relative_eq!(b.to_vector().magnitude(), 1.0, epsilon = 1e-14);
1157 }
1158 }
1159
1160 #[test]
1161 fn degenerate_weights_are_refused() {
1162 assert!(Weighted::new(Point::ORIGIN, 0.0, T).is_err());
1163 assert!(Weighted::new(Point::ORIGIN, -1.0, T).is_err());
1164 assert!(Weighted::new(Point::ORIGIN, f64::NAN, T).is_err());
1165 assert!(Weighted::new(Point::ORIGIN, f64::INFINITY, T).is_err());
1166 assert!(Weighted::new(Point::ORIGIN, 2.0, T).is_ok());
1167 }
1168
1169 #[test]
1170 fn weighted_round_trips_through_its_homogeneous_form() {
1171 let p = Point::new(3.0, -1.0, 2.0);
1172 let w = Weighted::new(p, 2.5, T).unwrap();
1173 assert!(w.point().is_equal(p, T));
1174 assert!(w.scaled.is_equal(Point::new(7.5, -2.5, 5.0), T));
1175 }
1176
1177 #[test]
1178 fn binomial_coefficients() {
1179 assert_eq!(binomial_coefficient(0, 0), 1);
1180 assert_eq!(binomial_coefficient(5, 0), 1);
1181 assert_eq!(binomial_coefficient(5, 5), 1);
1182 assert_eq!(binomial_coefficient(5, 2), 10);
1183 assert_eq!(binomial_coefficient(10, 5), 252);
1184 assert_eq!(binomial_coefficient(3, 4), 0);
1185 }
1186
1187 #[test]
1188 fn scalar_and_planar_control_points_work_too() {
1189 let k = KnotVector::clamped_uniform(2, 4).unwrap();
1192 let scalars = vec![0.0_f64, 1.0, 3.0, 2.0];
1193 assert_relative_eq!(evaluate(&k, &scalars, 0.0, T).unwrap(), 0.0);
1194 assert_relative_eq!(evaluate(&k, &scalars, 1.0, T).unwrap(), 2.0);
1195
1196 let planar = vec![
1197 Point2::new(0.0, 0.0),
1198 Point2::new(1.0, 2.0),
1199 Point2::new(3.0, 1.0),
1200 Point2::new(4.0, 0.0),
1201 ];
1202 assert!(
1203 evaluate(&k, &planar, 0.0, T)
1204 .unwrap()
1205 .is_equal(planar[0], T)
1206 );
1207 assert!(
1208 evaluate(&k, &planar, 1.0, T)
1209 .unwrap()
1210 .is_equal(planar[3], T)
1211 );
1212 }
1213}
1214
1215#[derive(Debug, Clone, PartialEq)]
1222pub struct ControlGrid<P> {
1223 points: Vec<P>,
1224 u_count: usize,
1225 v_count: usize,
1226}
1227
1228impl<P: Blend> ControlGrid<P> {
1229 pub fn new(points: Vec<P>, u_count: usize, v_count: usize) -> OgeomResult<Self> {
1236 if u_count == 0 || v_count == 0 {
1237 ogeom_bail!(Dimension, "control grid must be at least 1x1");
1238 }
1239 if points.len() != u_count * v_count {
1240 ogeom_bail!(
1241 Dimension,
1242 "a {u_count}x{v_count} grid needs {} points, got {}",
1243 u_count * v_count,
1244 points.len()
1245 );
1246 }
1247 Ok(Self {
1248 points,
1249 u_count,
1250 v_count,
1251 })
1252 }
1253
1254 #[must_use]
1256 pub const fn u_count(&self) -> usize {
1257 self.u_count
1258 }
1259
1260 #[must_use]
1262 pub const fn v_count(&self) -> usize {
1263 self.v_count
1264 }
1265
1266 #[must_use]
1268 pub fn get(&self, i: usize, j: usize) -> Option<P> {
1269 if i >= self.u_count || j >= self.v_count {
1270 return None;
1271 }
1272 self.points.get(i * self.v_count + j).copied()
1273 }
1274
1275 #[must_use]
1277 pub fn points(&self) -> &[P] {
1278 &self.points
1279 }
1280
1281 #[must_use]
1283 pub fn transposed(&self) -> Self {
1284 let mut points = Vec::with_capacity(self.points.len());
1285 for j in 0..self.v_count {
1286 for i in 0..self.u_count {
1287 points.push(self.points[i * self.v_count + j]);
1288 }
1289 }
1290 Self {
1291 points,
1292 u_count: self.v_count,
1293 v_count: self.u_count,
1294 }
1295 }
1296
1297 #[must_use]
1299 pub fn map<Q: Blend>(&self, f: impl Fn(P) -> Q) -> ControlGrid<Q> {
1300 ControlGrid {
1301 points: self.points.iter().map(|p| f(*p)).collect(),
1302 u_count: self.u_count,
1303 v_count: self.v_count,
1304 }
1305 }
1306}
1307
1308fn check_grid_shape<P>(ku: &KnotVector, kv: &KnotVector, grid: &ControlGrid<P>) -> OgeomResult<()> {
1310 if grid.u_count != ku.control_point_count() || grid.v_count != kv.control_point_count() {
1311 ogeom_bail!(
1312 Dimension,
1313 "knot vectors describe a {}x{} grid, got {}x{}",
1314 ku.control_point_count(),
1315 kv.control_point_count(),
1316 grid.u_count,
1317 grid.v_count
1318 );
1319 }
1320 Ok(())
1321}
1322
1323pub fn evaluate_surface<P: Blend>(
1335 ku: &KnotVector,
1336 kv: &KnotVector,
1337 grid: &ControlGrid<P>,
1338 u: f64,
1339 v: f64,
1340 tol: Tolerances,
1341) -> OgeomResult<P> {
1342 check_grid_shape(ku, kv, grid)?;
1343 let (p, q) = (ku.degree(), kv.degree());
1344 let (su, sv) = (ku.span(u, tol)?, kv.span(v, tol)?);
1345 let (nu, nv) = (ku.basis(su, u), kv.basis(sv, v));
1346
1347 let mut total = P::zero();
1348 for (i, &weight_u) in nu.iter().enumerate() {
1349 let mut row = P::zero();
1352 for (j, &weight_v) in nv.iter().enumerate() {
1353 let Some(point) = grid.get(su - p + i, sv - q + j) else {
1354 ogeom_bail!(Dimension, "control grid index out of range");
1355 };
1356 row = row.add(point.scale(weight_v));
1357 }
1358 total = total.add(row.scale(weight_u));
1359 }
1360 Ok(total)
1361}
1362
1363pub fn surface_derivatives<P: Blend>(
1372 ku: &KnotVector,
1373 kv: &KnotVector,
1374 grid: &ControlGrid<P>,
1375 u: f64,
1376 v: f64,
1377 order: usize,
1378 tol: Tolerances,
1379) -> OgeomResult<DerivativeGrid<P>> {
1380 check_grid_shape(ku, kv, grid)?;
1381 let (p, q) = (ku.degree(), kv.degree());
1382 let (su, sv) = (ku.span(u, tol)?, kv.span(v, tol)?);
1383 let du = ku.basis_derivatives(su, u, order);
1384 let dv = kv.basis_derivatives(sv, v, order);
1385
1386 let mut out: DerivativeGrid<P> =
1387 core::iter::repeat_with(|| core::iter::repeat_with(P::zero).take(order + 1).collect())
1388 .take(order + 1)
1389 .collect();
1390 for (k, row) in out.iter_mut().enumerate() {
1391 for (l, cell) in row.iter_mut().enumerate() {
1392 let mut total = P::zero();
1396 for (i, &weight_u) in du[k].iter().enumerate() {
1397 let mut inner = P::zero();
1398 for (j, &weight_v) in dv[l].iter().enumerate() {
1399 let Some(point) = grid.get(su - p + i, sv - q + j) else {
1400 ogeom_bail!(Dimension, "control grid index out of range");
1401 };
1402 inner = inner.add(point.scale(weight_v));
1403 }
1404 total = total.add(inner.scale(weight_u));
1405 }
1406 *cell = total;
1407 }
1408 }
1409 Ok(out)
1410}
1411
1412pub fn evaluate_rational_surface<P: Blend>(
1421 ku: &KnotVector,
1422 kv: &KnotVector,
1423 grid: &ControlGrid<Weighted<P>>,
1424 u: f64,
1425 v: f64,
1426 tol: Tolerances,
1427) -> OgeomResult<P> {
1428 let h = evaluate_surface(ku, kv, grid, u, v, tol)?;
1429 if h.weight.abs() <= tol.confusion() {
1430 ogeom_bail!(
1431 Numeric,
1432 "rational surface evaluation produced a vanishing weight"
1433 );
1434 }
1435 Ok(h.point())
1436}
1437
1438pub fn rational_surface_derivatives<P: Blend>(
1451 ku: &KnotVector,
1452 kv: &KnotVector,
1453 grid: &ControlGrid<Weighted<P>>,
1454 u: f64,
1455 v: f64,
1456 order: usize,
1457 tol: Tolerances,
1458) -> OgeomResult<DerivativeGrid<P>> {
1459 let h = surface_derivatives(ku, kv, grid, u, v, order, tol)?;
1460 let w0 = h[0][0].weight;
1461 if w0.abs() <= tol.confusion() {
1462 ogeom_bail!(
1463 Numeric,
1464 "rational surface evaluation produced a vanishing weight"
1465 );
1466 }
1467
1468 let mut s: DerivativeGrid<P> =
1469 core::iter::repeat_with(|| core::iter::repeat_with(P::zero).take(order + 1).collect())
1470 .take(order + 1)
1471 .collect();
1472 for k in 0..=order {
1473 for l in 0..=order {
1474 let mut value = h[k][l].scaled;
1475 #[allow(clippy::cast_precision_loss)]
1476 for i in 1..=k {
1477 let c = binomial_coefficient(k, i) as f64;
1478 value = value.sub(s[k - i][l].scale(c * h[i][0].weight));
1479 }
1480 #[allow(clippy::cast_precision_loss)]
1481 for j in 1..=l {
1482 let c = binomial_coefficient(l, j) as f64;
1483 value = value.sub(s[k][l - j].scale(c * h[0][j].weight));
1484 }
1485 #[allow(clippy::cast_precision_loss)]
1486 for i in 1..=k {
1487 let ci = binomial_coefficient(k, i) as f64;
1488 for j in 1..=l {
1489 let cj = binomial_coefficient(l, j) as f64;
1490 value = value.sub(s[k - i][l - j].scale(ci * cj * h[i][j].weight));
1491 }
1492 }
1493 s[k][l] = value.scale(1.0 / w0);
1494 }
1495 }
1496 Ok(s)
1497}
1498
1499#[cfg(test)]
1500#[allow(clippy::unwrap_used)]
1501mod surface_tests {
1502 use super::*;
1503 use approx::assert_relative_eq;
1504
1505 const T: Tolerances = Tolerances::millimetres();
1506
1507 fn patch() -> (KnotVector, KnotVector, ControlGrid<Point>) {
1509 let (nu, nv) = (5, 4);
1510 let mut points = Vec::with_capacity(nu * nv);
1511 for i in 0..nu {
1512 for j in 0..nv {
1513 #[allow(clippy::cast_precision_loss)]
1514 let (x, y) = (i as f64, j as f64);
1515 points.push(Point::new(x, y, (x * 0.7).sin() * (y * 0.5).cos()));
1516 }
1517 }
1518 (
1519 KnotVector::clamped_uniform(3, nu).unwrap(),
1520 KnotVector::clamped_uniform(2, nv).unwrap(),
1521 ControlGrid::new(points, nu, nv).unwrap(),
1522 )
1523 }
1524
1525 #[test]
1526 fn grid_shape_is_checked_on_construction() {
1527 assert!(ControlGrid::new(vec![Point::ORIGIN; 6], 2, 3).is_ok());
1528 assert!(ControlGrid::new(vec![Point::ORIGIN; 6], 3, 3).is_err());
1529 assert!(ControlGrid::new(Vec::<Point>::new(), 0, 3).is_err());
1530 }
1531
1532 #[test]
1533 fn grid_indexing_is_row_major_and_bounds_checked() {
1534 let g = ControlGrid::new(
1535 vec![
1536 Point::new(0.0, 0.0, 0.0),
1537 Point::new(0.0, 1.0, 0.0),
1538 Point::new(0.0, 2.0, 0.0),
1539 Point::new(1.0, 0.0, 0.0),
1540 Point::new(1.0, 1.0, 0.0),
1541 Point::new(1.0, 2.0, 0.0),
1542 ],
1543 2,
1544 3,
1545 )
1546 .unwrap();
1547 assert_eq!(g.get(1, 2), Some(Point::new(1.0, 2.0, 0.0)));
1548 assert_eq!(g.get(0, 1), Some(Point::new(0.0, 1.0, 0.0)));
1549 assert_eq!(g.get(2, 0), None);
1550 assert_eq!(g.get(0, 3), None);
1551 }
1552
1553 #[test]
1554 fn transposing_twice_is_the_identity() {
1555 let (_, _, g) = patch();
1556 let t = g.transposed();
1557 assert_eq!(t.u_count(), g.v_count());
1558 assert_eq!(t.v_count(), g.u_count());
1559 for i in 0..g.u_count() {
1560 for j in 0..g.v_count() {
1561 assert_eq!(t.get(j, i), g.get(i, j));
1562 }
1563 }
1564 assert_eq!(t.transposed(), g);
1565 }
1566
1567 #[test]
1568 fn a_clamped_patch_interpolates_its_corner_control_points() {
1569 let (ku, kv, g) = patch();
1570 let ((u0, u1), (v0, v1)) = (ku.domain(), kv.domain());
1571 let corners = [
1572 (u0, v0, g.get(0, 0).unwrap()),
1573 (u0, v1, g.get(0, g.v_count() - 1).unwrap()),
1574 (u1, v0, g.get(g.u_count() - 1, 0).unwrap()),
1575 (u1, v1, g.get(g.u_count() - 1, g.v_count() - 1).unwrap()),
1576 ];
1577 for (u, v, expected) in corners {
1578 assert!(
1579 evaluate_surface(&ku, &kv, &g, u, v, T)
1580 .unwrap()
1581 .is_equal(expected, T),
1582 "corner ({u}, {v})"
1583 );
1584 }
1585 }
1586
1587 #[test]
1588 fn surface_shape_mismatches_are_refused() {
1589 let (ku, kv, g) = patch();
1590 let wrong = ControlGrid::new(g.points().to_vec(), 4, 5).unwrap();
1591 assert!(evaluate_surface(&ku, &kv, &wrong, 0.5, 0.5, T).is_err());
1592 assert!(evaluate_surface(&ku, &kv, &g, 1.5, 0.5, T).is_err());
1593 assert!(evaluate_surface(&ku, &kv, &g, 0.5, -0.5, T).is_err());
1594 }
1595
1596 #[test]
1597 fn surface_partials_agree_with_finite_differences() {
1598 let (ku, kv, g) = patch();
1599 let h = 1e-6;
1600 for iu in 1..6 {
1601 for iv in 1..6 {
1602 let (u, v) = (f64::from(iu) / 6.0, f64::from(iv) / 6.0);
1603 let d = surface_derivatives(&ku, &kv, &g, u, v, 2, T).unwrap();
1604 assert!(d[0][0].is_equal(evaluate_surface(&ku, &kv, &g, u, v, T).unwrap(), T));
1605
1606 let du = (evaluate_surface(&ku, &kv, &g, u + h, v, T).unwrap()
1607 - evaluate_surface(&ku, &kv, &g, u - h, v, T).unwrap())
1608 * (1.0 / (2.0 * h));
1609 let dv = (evaluate_surface(&ku, &kv, &g, u, v + h, T).unwrap()
1610 - evaluate_surface(&ku, &kv, &g, u, v - h, T).unwrap())
1611 * (1.0 / (2.0 * h));
1612 assert!((d[1][0].to_vector() - du).magnitude() < 1e-5 * du.magnitude().max(1.0));
1613 assert!((d[0][1].to_vector() - dv).magnitude() < 1e-5 * dv.magnitude().max(1.0));
1614
1615 let mixed = (evaluate_surface(&ku, &kv, &g, u + h, v + h, T).unwrap()
1617 - evaluate_surface(&ku, &kv, &g, u + h, v - h, T).unwrap()
1618 - (evaluate_surface(&ku, &kv, &g, u - h, v + h, T).unwrap()
1619 - evaluate_surface(&ku, &kv, &g, u - h, v - h, T).unwrap()))
1620 * (1.0 / (4.0 * h * h));
1621 assert!(
1622 (d[1][1].to_vector() - mixed).magnitude() < 1e-3 * mixed.magnitude().max(1.0),
1623 "mixed partial wrong at ({u}, {v})"
1624 );
1625 }
1626 }
1627 }
1628
1629 fn rational_hemisphere() -> (KnotVector, KnotVector, ControlGrid<Weighted<Point>>) {
1632 let w = core::f64::consts::FRAC_1_SQRT_2;
1633 let rows: [[(Point, f64); 3]; 3] = [
1635 [
1636 (Point::new(1.0, 0.0, 0.0), 1.0),
1637 (Point::new(1.0, 1.0, 0.0), w),
1638 (Point::new(0.0, 1.0, 0.0), 1.0),
1639 ],
1640 [
1641 (Point::new(1.0, 0.0, 1.0), w),
1642 (Point::new(1.0, 1.0, 1.0), w * w),
1643 (Point::new(0.0, 1.0, 1.0), w),
1644 ],
1645 [
1646 (Point::new(0.0, 0.0, 1.0), 1.0),
1647 (Point::new(0.0, 0.0, 1.0), w),
1648 (Point::new(0.0, 0.0, 1.0), 1.0),
1649 ],
1650 ];
1651 let points: Vec<_> = rows
1652 .iter()
1653 .flatten()
1654 .map(|(p, w)| Weighted::new(*p, *w, T).unwrap())
1655 .collect();
1656 (
1657 KnotVector::clamped_uniform(2, 3).unwrap(),
1658 KnotVector::clamped_uniform(2, 3).unwrap(),
1659 ControlGrid::new(points, 3, 3).unwrap(),
1660 )
1661 }
1662
1663 #[test]
1664 fn a_rational_biquadratic_traces_an_exact_sphere() {
1665 let (ku, kv, g) = rational_hemisphere();
1666 for iu in 0..=10 {
1667 for iv in 0..=10 {
1668 let (u, v) = (f64::from(iu) / 10.0, f64::from(iv) / 10.0);
1669 let p = evaluate_rational_surface(&ku, &kv, &g, u, v, T).unwrap();
1670 assert_relative_eq!(
1671 p.to_vector().magnitude(),
1672 1.0,
1673 epsilon = 1e-13,
1674 max_relative = 1e-13
1675 );
1676 }
1677 }
1678 }
1679
1680 #[test]
1681 fn rational_surface_partials_agree_with_finite_differences() {
1682 let (ku, kv, g) = rational_hemisphere();
1683 let h = 1e-6;
1684 let at = |u: f64, v: f64| evaluate_rational_surface(&ku, &kv, &g, u, v, T).unwrap();
1685 for iu in 1..6 {
1686 for iv in 1..6 {
1687 let (u, v) = (f64::from(iu) / 6.0, f64::from(iv) / 6.0);
1688 let d = rational_surface_derivatives(&ku, &kv, &g, u, v, 2, T).unwrap();
1689 assert!(d[0][0].is_equal(at(u, v), T));
1690
1691 let du = (at(u + h, v) - at(u - h, v)) * (1.0 / (2.0 * h));
1692 let dv = (at(u, v + h) - at(u, v - h)) * (1.0 / (2.0 * h));
1693 assert!(
1694 (d[1][0].to_vector() - du).magnitude() < 1e-5 * du.magnitude().max(1.0),
1695 "du wrong at ({u}, {v})"
1696 );
1697 assert!(
1698 (d[0][1].to_vector() - dv).magnitude() < 1e-5 * dv.magnitude().max(1.0),
1699 "dv wrong at ({u}, {v})"
1700 );
1701
1702 let mixed =
1705 (at(u + h, v + h) - at(u + h, v - h) - (at(u - h, v + h) - at(u - h, v - h)))
1706 * (1.0 / (4.0 * h * h));
1707 assert!(
1708 (d[1][1].to_vector() - mixed).magnitude() < 1e-2 * mixed.magnitude().max(1.0),
1709 "mixed partial wrong at ({u}, {v}): {:?} vs {mixed:?}",
1710 d[1][1]
1711 );
1712 }
1713 }
1714 }
1715
1716 #[test]
1717 fn a_spheres_normal_is_radial() {
1718 let (ku, kv, g) = rational_hemisphere();
1721 for iu in 1..8 {
1722 for iv in 1..8 {
1723 let (u, v) = (f64::from(iu) / 8.0, f64::from(iv) / 8.0);
1724 let d = rational_surface_derivatives(&ku, &kv, &g, u, v, 1, T).unwrap();
1725 let radius = d[0][0].to_vector();
1726 let normal = d[1][0].to_vector().cross(d[0][1].to_vector());
1727 assert!(
1728 normal.magnitude() > 1e-6,
1729 "degenerate tangents at ({u}, {v})"
1730 );
1731 let sine =
1732 radius.cross(normal).magnitude() / (radius.magnitude() * normal.magnitude());
1733 assert!(sine < 1e-9, "normal not radial at ({u}, {v}): sine {sine}");
1734 }
1735 }
1736 }
1737
1738 #[test]
1739 fn uniform_weights_reduce_to_the_polynomial_surface() {
1740 let (ku, kv, g) = patch();
1741 let weighted = g.map(|p| Weighted {
1742 scaled: p.scale(2.0),
1743 weight: 2.0,
1744 });
1745 for iu in 0..=6 {
1746 for iv in 0..=6 {
1747 let (u, v) = (f64::from(iu) / 6.0, f64::from(iv) / 6.0);
1748 let plain = evaluate_surface(&ku, &kv, &g, u, v, T).unwrap();
1749 let rational = evaluate_rational_surface(&ku, &kv, &weighted, u, v, T).unwrap();
1750 assert!(plain.is_equal(rational, T));
1751 }
1752 }
1753 }
1754}