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(
471 &self,
472 max_degree: usize,
473 tolerance: f64,
474 tol: Tolerances,
475 ) -> OgeomResult<Fitted<Self>> {
476 if max_degree == 0 {
477 ogeom_bail!(Construction, "a patch needs a degree of at least one");
478 }
479 if self.u_knots().degree() <= max_degree && self.v_knots().degree() <= max_degree {
480 return Ok(Fitted {
481 curve: self.clone(),
482 error: 0.0,
483 met: true,
484 });
485 }
486 grid_fitted(
487 |u, v| self.point_at(u, v, tol),
488 self.domain(),
489 max_degree,
490 tolerance,
491 tol,
492 )
493 }
494}
495
496fn grid_fitted(
507 point: impl Fn(f64, f64) -> OgeomResult<Point>,
508 domain: ((f64, f64), (f64, f64)),
509 degree: usize,
510 tolerance: f64,
511 tol: Tolerances,
512) -> OgeomResult<Fitted<crate::surface::BSplineSurface>> {
513 const LIMIT_SHARE: f64 = 1e-3;
514 let ((ua, ub), (va, vb)) = domain;
515 if ![ua, ub, va, vb].iter().all(|x| x.is_finite()) || ub <= ua || vb <= va {
516 ogeom_bail!(
517 Construction,
518 "an unbounded or empty surface cannot be fitted"
519 );
520 }
521 let slack = core::cell::Cell::new(0.0_f64);
522 let limited = |u: f64, v: f64| -> OgeomResult<Point> {
523 point(u, v).or_else(|refused| {
524 let (at, spread) = limit_at(&point, domain, (u, v), tolerance * LIMIT_SHARE)
525 .ok_or_else(|| {
526 ogeom_err!(
527 Construction,
528 "the surface cannot be evaluated at ({u}, {v}) and has no limit there: \
529 {refused}"
530 )
531 })?;
532 slack.set(slack.get().max(spread));
533 Ok(at)
534 })
535 };
536 let mut fitted = fit::fit_surface_sampled(
537 limited,
538 &fit::Sampling::even(domain, 16),
539 degree,
540 tolerance * (1.0 - LIMIT_SHARE),
541 tol,
542 )?;
543 fitted.error += slack.get();
544 fitted.met = fitted.error <= tolerance;
545 Ok(fitted)
546}
547
548fn limit_at(
557 point: &impl Fn(f64, f64) -> OgeomResult<Point>,
558 ((ua, ub), (va, vb)): ((f64, f64), (f64, f64)),
559 (u, v): (f64, f64),
560 spread: f64,
561) -> Option<(Point, f64)> {
562 const AROUND: u32 = 16;
563 let mut h = 1e-5;
564 for _ in 0..6 {
565 let ring: Vec<Point> = (0..AROUND)
566 .filter_map(|i| {
567 let a = TAU * f64::from(i) / f64::from(AROUND);
568 let pu = (u + h * (ub - ua) * a.cos()).clamp(ua, ub);
569 let pv = (v + h * (vb - va) * a.sin()).clamp(va, vb);
570 point(pu, pv).ok()
571 })
572 .collect();
573 h *= 0.1;
574 if ring.len() < 2 {
575 continue;
576 }
577 let sum = ring
578 .iter()
579 .fold(ogeom_math::Vector::ZERO, |s, p| s + p.to_vector());
580 let centre = Point::from_vector(sum / f64::from(u32::try_from(ring.len()).ok()?));
581 let widest = ring.iter().map(|p| p.distance(centre)).fold(0.0, f64::max);
582 if widest <= spread {
583 return Some((centre, widest));
584 }
585 }
586 None
587}
588
589impl crate::surface::SurfaceGeometry {
590 pub fn fitted_bspline(
612 &self,
613 tolerance: f64,
614 tol: Tolerances,
615 ) -> OgeomResult<Fitted<crate::surface::BSplineSurface>> {
616 use crate::traits::Surface as _;
617 grid_fitted(
618 |u, v| self.point_at(u, v, tol),
619 self.domain(),
620 3,
621 tolerance,
622 tol,
623 )
624 }
625}
626
627fn same_parameter_fit(
631 curve: &Curve,
632 range: (f64, f64),
633 degree: usize,
634 tolerance: f64,
635 tol: Tolerances,
636) -> OgeomResult<Fitted<BSplineCurve>> {
637 let (lo, hi) = range;
638 if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
639 ogeom_bail!(Construction, "cannot fit over an empty range [{lo}, {hi}]");
640 }
641 if !(tolerance > 0.0 && tolerance.is_finite()) {
642 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
643 }
644 let mut samples = 32usize;
645 let mut best: Option<Fitted<BSplineCurve>> = None;
646 for _ in 0..8 {
647 #[allow(clippy::cast_precision_loss)]
648 let params: Vec<f64> = (0..=samples)
649 .map(|i| lo + (hi - lo) * i as f64 / samples as f64)
650 .collect();
651 let points = params
652 .iter()
653 .map(|t| curve.point_at(*t, tol))
654 .collect::<OgeomResult<Vec<Point>>>()?;
655 let fitted = fit::fit_points_at(¶ms, &points, degree, tolerance, tol)?;
656 let mut error = fitted.error;
657 for pair in params.windows(2) {
658 let t = f64::midpoint(pair[0], pair[1]);
659 error = error.max(
660 curve
661 .point_at(t, tol)?
662 .distance(fitted.curve.point_at(t, tol)?),
663 );
664 }
665 let candidate = Fitted {
666 curve: fitted.curve,
667 error,
668 met: error <= tolerance,
669 };
670 if candidate.met {
671 return Ok(candidate);
672 }
673 if best.as_ref().is_none_or(|b| error < b.error) {
674 best = Some(candidate);
675 }
676 samples *= 2;
677 }
678 best.ok_or_else(|| ogeom_err!(Construction, "the curve could not be sampled"))
679}
680
681fn normalized(curve: BSplineCurve) -> OgeomResult<BSplineCurve> {
682 let knots = curve.knots().reparameterized(0.0, 1.0)?;
683 BSplineCurve::rational(knots, curve.control_points().to_vec())
684}
685
686#[cfg(test)]
687#[allow(clippy::unwrap_used)]
688mod tests {
689 use super::*;
690 use core::f64::consts::{FRAC_PI_2, PI};
691 use ogeom_math::{Circle, Direction, Ellipse, Hyperbola, Parabola, Vector};
692
693 use crate::curve::{
694 CircleCurve, EllipseCurve, HyperbolaCurve, LineCurve, ParabolaCurve, TrimmedCurve,
695 };
696
697 const T: Tolerances = Tolerances::millimetres();
698
699 #[test]
704 fn a_helix_fits_to_a_stated_tolerance_at_its_own_parameters() {
705 let helix: Curve = crate::curve::HelixCurve::new(ogeom_math::Frame::WORLD, 5.0, 4.0, 2.0)
706 .unwrap()
707 .into();
708 assert!(helix.to_bspline(T).is_err(), "the exact conversion refuses");
709 let domain = helix.domain();
710 let fitted = helix.fitted_bspline_over(domain, 1e-3, T).unwrap();
711 assert!(fitted.met && fitted.error <= 1e-3, "error {}", fitted.error);
712 let (lo, hi) = fitted.curve.domain();
713 assert!((lo - domain.0).abs() < 1e-12 && (hi - domain.1).abs() < 1e-12);
714 for i in 0..=200 {
715 let t = domain.0 + (domain.1 - domain.0) * f64::from(i) / 200.0;
716 let gap = helix
717 .point_at(t, T)
718 .unwrap()
719 .distance(fitted.curve.point_at(t, T).unwrap());
720 assert!(gap <= 1e-3, "same-parameter within tolerance at {t}: {gap}");
721 }
722 }
723
724 #[test]
727 fn a_curve_restricted_in_degree_holds_its_tolerance() {
728 let cubic = BSplineCurve::new(
729 ogeom_math::KnotVector::clamped_uniform(3, 6).unwrap(),
730 vec![
731 Point::new(0.0, 0.0, 0.0),
732 Point::new(1.0, 2.0, 0.5),
733 Point::new(2.5, 1.0, -0.5),
734 Point::new(4.0, 3.0, 1.0),
735 Point::new(5.0, 0.5, 0.0),
736 Point::new(6.0, 2.0, 2.0),
737 ],
738 T,
739 )
740 .unwrap();
741 let quintic = cubic.elevated(T).unwrap().elevated(T).unwrap();
742 assert_eq!(quintic.degree(), 5);
743 let same = quintic.restricted_to_degree(5, 1e-6, T).unwrap();
744 assert!(same.met && same.error == 0.0 && same.curve.degree() == 5);
745 let back = quintic.restricted_to_degree(3, 1e-6, T).unwrap();
746 assert!(back.met && back.curve.degree() == 3, "error {}", back.error);
747 let (lo, hi) = cubic.domain();
748 for i in 0..=50 {
749 let t = lo + (hi - lo) * f64::from(i) / 50.0;
750 let gap = cubic
751 .point_at(t, T)
752 .unwrap()
753 .distance(back.curve.point_at(t, T).unwrap());
754 assert!(gap < 1e-6, "the cubic again at {t}: {gap}");
755 }
756 }
757
758 #[test]
761 fn a_patch_restricted_in_degree_holds_its_samples() {
762 let (nu, nv) = (5, 4);
763 let mut points = Vec::with_capacity(nu * nv);
764 for i in 0..nu {
765 for j in 0..nv {
766 #[allow(clippy::cast_precision_loss)]
767 let (x, y) = (i as f64, j as f64);
768 points.push(Point::new(x, y, (x * 0.7).sin() * (y * 0.5).cos() * 0.3));
769 }
770 }
771 let patch = crate::surface::BSplineSurface::new(
772 ogeom_math::KnotVector::clamped_uniform(3, nu).unwrap(),
773 ogeom_math::KnotVector::clamped_uniform(3, nv).unwrap(),
774 &ogeom_math::ControlGrid::new(points, nu, nv).unwrap(),
775 T,
776 )
777 .unwrap();
778 let lower = patch.restricted_to_degree(2, 1e-2, T).unwrap();
779 assert!(lower.met && lower.error <= 1e-2, "error {}", lower.error);
780 assert!(lower.curve.u_knots().degree() == 2 && lower.curve.v_knots().degree() == 2);
781 let same = patch.restricted_to_degree(3, 1e-2, T).unwrap();
782 assert!(same.met && same.error == 0.0);
783 }
784
785 fn deviation(original: &Curve, converted: &BSplineCurve, samples: usize) -> f64 {
792 let (a, b) = converted.knots().domain();
793 let mut worst = 0.0_f64;
794 for i in 0..=samples {
795 #[allow(clippy::cast_precision_loss)]
796 let u = a + (b - a) * i as f64 / samples as f64;
797 let p = converted.point_at(u, T).unwrap();
798 worst = worst.max(distance_to(original, p));
799 }
800 worst
801 }
802
803 fn distance_to(curve: &Curve, p: Point) -> f64 {
805 match curve {
806 Curve::Circle(c) => c.circle().distance_to(p),
807 Curve::Ellipse(e) => {
808 nearest(curve, p, e.ellipse().major_radius())
810 }
811 _ => nearest(curve, p, 1.0),
812 }
813 }
814
815 fn nearest(curve: &Curve, p: Point, _scale: f64) -> f64 {
823 const SCAN: usize = 2_000;
824 let (a, b) = curve.domain();
825 let at = |u: f64| curve.point_at(u, T).map_or(f64::MAX, |q| p.distance(q));
826
827 let mut best = (a, f64::MAX);
828 for i in 0..=SCAN {
829 #[allow(clippy::cast_precision_loss)]
830 let u = a + (b - a) * i as f64 / SCAN as f64;
831 let d = at(u);
832 if d < best.1 {
833 best = (u, d);
834 }
835 }
836 #[allow(clippy::cast_precision_loss)]
839 let step = (b - a) / SCAN as f64;
840 let (mut lo, mut hi) = (best.0 - step, best.0 + step);
841 for _ in 0..200 {
842 let one = lo + (hi - lo) / 3.0;
843 let two = hi - (hi - lo) / 3.0;
844 if at(one) < at(two) {
845 hi = two;
846 } else {
847 lo = one;
848 }
849 }
850 at(f64::midpoint(lo, hi)).min(best.1)
851 }
852
853 #[test]
854 fn a_line_becomes_a_degree_one_spline_through_its_own_ends() {
855 let from = Point::new(1.0, 2.0, 3.0);
856 let to = Point::new(4.0, -1.0, 0.5);
857 let line: Curve = LineCurve::segment(from, to, T).unwrap().into();
858 let spline = line.to_bspline(T).unwrap();
859
860 assert_eq!(spline.degree(), 1);
861 assert!(!spline.is_rational(), "a line needs no weights");
862 assert!(spline.point_at(0.0, T).unwrap().is_equal(from, T));
863 assert!(spline.point_at(1.0, T).unwrap().is_equal(to, T));
864 assert!(spline.point_at(0.5, T).unwrap().is_equal(
865 Point::from_vector((from.to_vector() + to.to_vector()) * 0.5),
866 T
867 ));
868 }
869
870 #[test]
871 fn a_full_circle_becomes_an_exact_rational_quadratic() {
872 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.5, T).unwrap()).into();
876 let spline = circle.to_bspline(T).unwrap();
877
878 assert_eq!(spline.degree(), 2);
879 assert!(spline.is_rational(), "a circle needs its weights");
880 assert!(
881 deviation(&circle, &spline, 500) < 1e-12,
882 "off the circle by {}",
883 deviation(&circle, &spline, 500)
884 );
885 assert!(
887 spline
888 .point_at(0.0, T)
889 .unwrap()
890 .is_equal(spline.point_at(1.0, T).unwrap(), T)
891 );
892 }
893
894 #[test]
895 fn an_arc_covers_its_own_span_and_no_more() {
896 let circle = Circle::new(Frame::WORLD, 3.0, T).unwrap();
899 let curve: Curve = CircleCurve::new(circle).into();
900 for (from, to) in [
901 (0.0, FRAC_PI_2),
902 (0.3, 1.9),
903 (PI, PI * 1.5),
904 (0.0, PI * 1.75),
905 ] {
906 let spline = curve.to_bspline_over((from, to), T).unwrap();
907 assert!(
908 spline
909 .point_at(0.0, T)
910 .unwrap()
911 .is_equal(curve.point_at(from, T).unwrap(), T),
912 "arc [{from}, {to}] starts in the wrong place"
913 );
914 assert!(
915 spline
916 .point_at(1.0, T)
917 .unwrap()
918 .is_equal(curve.point_at(to, T).unwrap(), T),
919 "arc [{from}, {to}] ends in the wrong place"
920 );
921 assert!(deviation(&curve, &spline, 200) < 1e-12);
922 }
923 }
924
925 #[test]
926 fn an_ellipse_uses_the_same_construction_and_the_same_weights() {
927 let ellipse: Curve =
931 EllipseCurve::new(Ellipse::new(Frame::WORLD, 5.0, 2.0, T).unwrap()).into();
932 let spline = ellipse.to_bspline(T).unwrap();
933 assert_eq!(spline.degree(), 2);
934 assert!(deviation(&ellipse, &spline, 400) < 1e-9);
935
936 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 5.0, T).unwrap()).into();
937 let round = circle.to_bspline(T).unwrap();
938 let weights: Vec<f64> = spline.control_points().iter().map(|c| c.weight).collect();
939 let same: Vec<f64> = round.control_points().iter().map(|c| c.weight).collect();
940 assert_eq!(weights, same, "the weights should not depend on the radii");
941 }
942
943 #[test]
944 fn a_conic_in_a_tilted_frame_converts_where_it_actually_is() {
945 let frame = Frame::new(
946 Point::new(3.0, -2.0, 7.0),
947 Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
948 Direction::from_coords(1.0, -1.0, 0.0, T).unwrap(),
949 T,
950 )
951 .unwrap();
952 let circle: Curve = CircleCurve::new(Circle::new(frame, 4.0, T).unwrap()).into();
953 let spline = circle.to_bspline(T).unwrap();
954 assert!(deviation(&circle, &spline, 300) < 1e-12);
955 }
956
957 #[test]
958 fn a_parabola_is_a_polynomial_quadratic_exactly() {
959 let parabola: Curve = ParabolaCurve::new(Parabola::new(Frame::WORLD, 1.5, T).unwrap(), 4.0)
962 .unwrap()
963 .into();
964 let spline = parabola.to_bspline(T).unwrap();
965 assert_eq!(spline.degree(), 2);
966 assert!(deviation(¶bola, &spline, 300) < 1e-9);
967 for c in spline.control_points() {
968 assert!(
969 (c.weight - 1.0).abs() < 1e-9,
970 "a parabola should need no weights, got {}",
971 c.weight
972 );
973 }
974 }
975
976 #[test]
977 fn a_hyperbola_becomes_a_rational_quadratic() {
978 let hyperbola: Curve =
979 HyperbolaCurve::new(Hyperbola::new(Frame::WORLD, 2.0, 1.0, T).unwrap(), 1.0)
980 .unwrap()
981 .into();
982 let spline = hyperbola.to_bspline(T).unwrap();
983 assert_eq!(spline.degree(), 2);
984 assert!(
985 deviation(&hyperbola, &spline, 300) < 1e-9,
986 "off by {}",
987 deviation(&hyperbola, &spline, 300)
988 );
989 }
990
991 #[test]
992 fn a_spline_converts_to_itself_and_a_trimmed_one_to_its_piece() {
993 let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0], 2).unwrap();
994 let control = vec![
995 Point::ORIGIN,
996 Point::new(1.0, 2.0, 0.0),
997 Point::new(3.0, 2.0, 1.0),
998 Point::new(4.0, 0.0, 0.0),
999 ];
1000 let spline: Curve = BSplineCurve::new(knots, control, T).unwrap().into();
1001 let same = spline.to_bspline(T).unwrap();
1002 assert!(deviation(&spline, &same, 200) < 1e-12);
1003
1004 let trimmed: Curve = Curve::Trimmed(Box::new(
1005 TrimmedCurve::new(spline.clone(), 0.25, 0.75, T).unwrap(),
1006 ));
1007 let piece = trimmed.to_bspline(T).unwrap();
1008 assert!(
1009 piece
1010 .point_at(0.0, T)
1011 .unwrap()
1012 .is_equal(spline.point_at(0.25, T).unwrap(), T)
1013 );
1014 assert!(
1015 piece
1016 .point_at(1.0, T)
1017 .unwrap()
1018 .is_equal(spline.point_at(0.75, T).unwrap(), T)
1019 );
1020 assert!(deviation(&spline, &piece, 200) < 1e-12);
1021 }
1022
1023 #[test]
1024 fn a_reversed_conic_converts_to_the_curve_it_actually_traces() {
1025 use crate::traits::Reversible;
1026 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1027 let backwards = circle.reversed();
1028 let spline = backwards.to_bspline_over((0.0, FRAC_PI_2), T).unwrap();
1029
1030 assert!(
1031 spline
1032 .point_at(0.0, T)
1033 .unwrap()
1034 .is_equal(backwards.point_at(0.0, T).unwrap(), T),
1035 "a reversed arc should start where the reversed curve does"
1036 );
1037 assert!(
1038 spline
1039 .point_at(1.0, T)
1040 .unwrap()
1041 .is_equal(backwards.point_at(FRAC_PI_2, T).unwrap(), T)
1042 );
1043 }
1044
1045 #[test]
1046 fn an_empty_range_is_refused() {
1047 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1048 assert!(circle.to_bspline_over((1.0, 1.0), T).is_err());
1049 assert!(circle.to_bspline_over((1.0, 0.0), T).is_err());
1050 assert!(circle.to_bspline_over((0.0, f64::NAN), T).is_err());
1051 let _ = Vector::ZERO;
1052 }
1053}
1054
1055impl crate::surface::SurfaceGeometry {
1058 pub fn to_bspline(&self, tol: Tolerances) -> OgeomResult<crate::surface::BSplineSurface> {
1075 self.to_bspline_over(self.domain(), tol)
1076 }
1077
1078 fn to_bspline_over(
1087 &self,
1088 window: ((f64, f64), (f64, f64)),
1089 tol: Tolerances,
1090 ) -> OgeomResult<crate::surface::BSplineSurface> {
1091 use crate::surface::SurfaceGeometry as S;
1092 let ((ua, ub), (va, vb)) = window;
1093 match self {
1094 S::Offset(_) => ogeom_bail!(
1098 Construction,
1099 "an offset surface has no exact B-spline form; offset the basis analytically \
1100 or fit at a stated tolerance instead"
1101 ),
1102
1103 S::Plane(_) => {
1106 let corner = |u: f64, v: f64| self.point_at(u, v, tol);
1107 bilinear(
1108 corner(ua, va)?,
1109 corner(ub, va)?,
1110 corner(ua, vb)?,
1111 corner(ub, vb)?,
1112 )
1113 }
1114
1115 S::Cylinder(_) | S::Cone(_) => {
1118 let profile = |v: f64| -> OgeomResult<Vec<Weighted<Point>>> {
1119 let mut out = Vec::new();
1120 let ring = self.section_at(v, (ua, ub), tol)?;
1121 out.extend_from_slice(ring.control_points());
1122 Ok(out)
1123 };
1124 let (knots, _) = {
1125 let ring = self.section_at(va, (ua, ub), tol)?;
1126 (ring.knots().clone(), ())
1127 };
1128 loft(&knots, &profile(va)?, &profile(vb)?)
1129 }
1130
1131 S::Sphere(sp) => {
1134 let sphere = sp.sphere();
1135 let frame = sphere.frame();
1136 let meridian = Frame::new(frame.origin(), -frame.y(), frame.x(), tol)?;
1140 let circle = ogeom_math::Circle::new(meridian, sphere.radius(), tol)?;
1141 let profile: Curve = crate::curve::CircleCurve::new(circle).into();
1142 revolved_patch(
1143 &profile,
1144 (va, vb),
1145 ogeom_math::Axis {
1146 location: frame.origin(),
1147 direction: frame.z(),
1148 },
1149 (ua, ub),
1150 tol,
1151 )
1152 }
1153 S::Torus(t) => {
1154 let torus = t.torus();
1155 let frame = torus.frame();
1156 let centre = frame.origin() + frame.x().vector() * torus.major_radius();
1157 let tube = Frame::new(centre, -frame.y(), frame.x(), tol)?;
1158 let circle = ogeom_math::Circle::new(tube, torus.minor_radius(), tol)?;
1159 let profile: Curve = crate::curve::CircleCurve::new(circle).into();
1160 revolved_patch(
1161 &profile,
1162 (va, vb),
1163 ogeom_math::Axis {
1164 location: frame.origin(),
1165 direction: frame.z(),
1166 },
1167 (ua, ub),
1168 tol,
1169 )
1170 }
1171 S::Revolution(r) => revolved_patch(r.curve(), (va, vb), r.axis(), (ua, ub), tol),
1172
1173 S::Extrusion(e) => {
1174 let base = e.curve().to_bspline_over((ua, ub), tol)?;
1175 let along = e.direction().vector() * (vb - va);
1176 let start: Vec<Weighted<Point>> = base
1177 .control_points()
1178 .iter()
1179 .map(|c| Weighted {
1180 scaled: Point::from_vector(
1181 c.scaled.to_vector() + e.direction().vector() * va * c.weight,
1182 ),
1183 weight: c.weight,
1184 })
1185 .collect();
1186 let end: Vec<Weighted<Point>> = start
1187 .iter()
1188 .map(|c| Weighted {
1189 scaled: Point::from_vector(c.scaled.to_vector() + along * c.weight),
1190 weight: c.weight,
1191 })
1192 .collect();
1193 loft(base.knots(), &start, &end)
1194 }
1195
1196 S::BSpline(s) => {
1197 let whole = s.domain();
1198 let near = |a: (f64, f64), b: (f64, f64)| {
1199 (a.0 - b.0).abs() <= tol.parametric() && (a.1 - b.1).abs() <= tol.parametric()
1200 };
1201 if near(whole.0, (ua, ub)) && near(whole.1, (va, vb)) {
1202 Ok(s.clone())
1203 } else {
1204 s.segment((ua, ub), (va, vb), tol)
1205 }
1206 }
1207
1208 S::Trimmed(t) => t.basis().to_bspline_over(window, tol),
1209 }
1210 }
1211
1212 fn section_at(
1214 &self,
1215 v: f64,
1216 u_range: (f64, f64),
1217 tol: Tolerances,
1218 ) -> OgeomResult<crate::curve::BSplineCurve> {
1219 use crate::surface::SurfaceGeometry as S;
1220 let (frame, radius) = match self {
1221 S::Cylinder(c) => (c.cylinder().frame(), c.cylinder().radius()),
1222 S::Cone(c) => (c.cone().frame(), c.cone().radius_at(v).abs()),
1223 _ => ogeom_bail!(Construction, "this surface has no circular section"),
1224 };
1225 let at = Frame::from_axes(
1228 frame.origin() + frame.z() * v,
1229 frame.x(),
1230 frame.y(),
1231 frame.z(),
1232 tol,
1233 )?;
1234 conic_arc(at, radius, radius, u_range.0, u_range.1, tol)
1235 }
1236}
1237
1238fn revolved_patch(
1248 profile: &Curve,
1249 v_range: (f64, f64),
1250 axis: ogeom_math::Axis,
1251 u_range: (f64, f64),
1252 tol: Tolerances,
1253) -> OgeomResult<crate::surface::BSplineSurface> {
1254 let frame = Frame::about(axis.location, axis.direction);
1255 let turn: Curve =
1256 crate::curve::CircleCurve::new(ogeom_math::Circle::new(frame, 1.0, tol)?).into();
1257 let arc = turn.to_bspline_over(u_range, tol)?;
1258 let pro = profile.to_bspline_over(v_range, tol)?;
1259
1260 let locals: Vec<(f64, f64, f64, f64)> = pro
1261 .control_points()
1262 .iter()
1263 .map(|c| {
1264 let l = frame.to_local(Point::from_vector(c.scaled.to_vector() / c.weight));
1265 (l.x, l.y, l.z, c.weight)
1266 })
1267 .collect();
1268 let mut points = Vec::with_capacity(arc.control_points().len() * locals.len());
1269 for ci in arc.control_points() {
1270 let l = frame.to_local(Point::from_vector(ci.scaled.to_vector() / ci.weight));
1271 let (a, b) = (l.x, l.y);
1272 for &(x, y, z, wj) in &locals {
1273 let weight = ci.weight * wj;
1274 let rotated =
1275 frame.to_world(Point::new(a.mul_add(x, -(b * y)), b.mul_add(x, a * y), z));
1276 points.push(Weighted {
1277 scaled: Point::from_vector(rotated.to_vector() * weight),
1278 weight,
1279 });
1280 }
1281 }
1282 let grid = ogeom_math::ControlGrid::new(points, arc.control_points().len(), locals.len())?;
1283 crate::surface::BSplineSurface::rational(arc.knots().clone(), pro.knots().clone(), grid)
1284}
1285
1286fn bilinear(a: Point, b: Point, c: Point, d: Point) -> OgeomResult<crate::surface::BSplineSurface> {
1288 let line = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
1289 let grid = ogeom_math::ControlGrid::new(
1290 vec![
1291 Weighted {
1292 scaled: a,
1293 weight: 1.0,
1294 },
1295 Weighted {
1296 scaled: c,
1297 weight: 1.0,
1298 },
1299 Weighted {
1300 scaled: b,
1301 weight: 1.0,
1302 },
1303 Weighted {
1304 scaled: d,
1305 weight: 1.0,
1306 },
1307 ],
1308 2,
1309 2,
1310 )?;
1311 crate::surface::BSplineSurface::rational(line.clone(), line, grid)
1312}
1313
1314fn loft(
1316 across: &KnotVector,
1317 start: &[Weighted<Point>],
1318 end: &[Weighted<Point>],
1319) -> OgeomResult<crate::surface::BSplineSurface> {
1320 if start.len() != end.len() {
1321 ogeom_bail!(
1322 Dimension,
1323 "a ruled patch needs the same control points at each end, got {} \
1324 and {}",
1325 start.len(),
1326 end.len()
1327 );
1328 }
1329 let mut points = Vec::with_capacity(start.len() * 2);
1330 for (a, b) in start.iter().zip(end) {
1331 points.push(*a);
1332 points.push(*b);
1333 }
1334 let grid = ogeom_math::ControlGrid::new(points, start.len(), 2)?;
1335 let along = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
1336 crate::surface::BSplineSurface::rational(across.clone(), along, grid)
1337}
1338
1339#[cfg(test)]
1340#[allow(clippy::unwrap_used)]
1341mod surface_tests {
1342 use super::*;
1343 use crate::surface::{
1344 ConeSurface, CylinderSurface, ExtrusionSurface, PlaneSurface, SphereSurface,
1345 SurfaceGeometry, TorusSurface, TrimmedSurface,
1346 };
1347 use ogeom_math::{Circle, Cone, Cylinder, Direction, Plane, Sphere, Torus};
1348
1349 const T: Tolerances = Tolerances::millimetres();
1350
1351 #[test]
1355 fn a_mirrored_cylinder_or_cone_converts_turning_its_way() {
1356 use crate::traits::Transformable as _;
1357 use ogeom_math::{Transform, elementary};
1358 let mirror = Transform::plane_mirror(Point::ORIGIN, Direction::X);
1359 let surfaces: Vec<SurfaceGeometry> = vec![
1360 CylinderSurface::new(Cylinder::new(Frame::WORLD, 5.0, T).unwrap(), (0.0, 10.0))
1361 .unwrap()
1362 .into(),
1363 ConeSurface::new(Cone::new(Frame::WORLD, 5.0, 0.3, T).unwrap(), (0.0, 10.0))
1364 .unwrap()
1365 .into(),
1366 ];
1367 for original in surfaces {
1368 let mirrored = original.transformed(&mirror, T).unwrap();
1369 let spline = mirrored.to_bspline(T).unwrap();
1370 let ((pa, pb), (qa, qb)) = spline.domain();
1371 for i in 1..8 {
1372 for j in 1..8 {
1373 let (u, v) = (
1374 pa + (pb - pa) * f64::from(i) / 8.0,
1375 qa + (qb - qa) * f64::from(j) / 8.0,
1376 );
1377 let p = spline.point_at(u, v, T).unwrap();
1378 let (au, av) = match &mirrored {
1379 SurfaceGeometry::Cylinder(c) => {
1380 elementary::cylinder_parameters(&c.cylinder(), p, T).unwrap()
1381 }
1382 SurfaceGeometry::Cone(c) => {
1383 elementary::cone_parameters(&c.cone(), p, T).unwrap()
1384 }
1385 _ => unreachable!(),
1386 };
1387 let (a, b) = (
1388 spline.normal_at(u, v, T).unwrap(),
1389 mirrored.normal_at(au, av, T).unwrap(),
1390 );
1391 assert!(
1392 a.vector().dot(b.vector()) > 0.99,
1393 "{mirrored:?}: {a:?} against {b:?}"
1394 );
1395 }
1396 }
1397 }
1398 }
1399
1400 fn deviation(distance: impl Fn(Point) -> f64, patch: &crate::surface::BSplineSurface) -> f64 {
1409 let ((pa, pb), (qa, qb)) = patch.domain();
1410 let mut worst = 0.0_f64;
1411 for i in 0..=60 {
1412 for j in 0..=60 {
1413 #[allow(clippy::cast_precision_loss)]
1414 let (s, t) = (i as f64 / 60.0, j as f64 / 60.0);
1415 if let Ok(p) = patch.point_at(pa + (pb - pa) * s, qa + (qb - qa) * t, T) {
1416 worst = worst.max(distance(p).abs());
1417 }
1418 }
1419 }
1420 worst
1421 }
1422
1423 fn spans_the_same(original: &SurfaceGeometry, patch: &crate::surface::BSplineSurface) -> bool {
1428 let ((ua, ub), (va, vb)) = original.domain();
1429 let ((pa, pb), (qa, qb)) = patch.domain();
1430 [
1431 (ua, va, pa, qa),
1432 (ua, vb, pa, qb),
1433 (ub, va, pb, qa),
1434 (ub, vb, pb, qb),
1435 ]
1436 .iter()
1437 .all(|(u, v, p, q)| {
1438 match (original.point_at(*u, *v, T), patch.point_at(*p, *q, T)) {
1439 (Ok(a), Ok(b)) => a.is_equal(b, T),
1440 _ => false,
1441 }
1442 })
1443 }
1444
1445 #[test]
1446 fn a_plane_becomes_a_bilinear_patch() {
1447 let plane: SurfaceGeometry =
1448 PlaneSurface::over(Plane::new(Frame::WORLD), (-2.0, 5.0), (-1.0, 3.0))
1449 .unwrap()
1450 .into();
1451 let patch = plane.to_bspline(T).unwrap();
1452 assert!(!patch.is_rational(), "a plane needs no weights");
1453 let flat = Plane::new(Frame::WORLD);
1454 assert!(deviation(|p| flat.distance_to(p), &patch) < 1e-12);
1455 assert!(spans_the_same(&plane, &patch));
1456 }
1457
1458 #[test]
1459 fn a_cylinder_becomes_an_exact_rational_patch() {
1460 let cylinder: SurfaceGeometry =
1463 CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (0.0, 5.0))
1464 .unwrap()
1465 .into();
1466 let patch = cylinder.to_bspline(T).unwrap();
1467 assert!(patch.is_rational());
1468 let exact = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
1469 let off = deviation(|p| exact.distance_to(p), &patch);
1470 assert!(off < 1e-12, "off the cylinder by {off}");
1471 assert!(spans_the_same(&cylinder, &patch));
1472 }
1473
1474 #[test]
1475 fn a_cone_becomes_an_exact_rational_patch() {
1476 let cone: SurfaceGeometry = ConeSurface::new(
1477 Cone::new(Frame::WORLD, 1.0, 0.4_f64.atan(), T).unwrap(),
1478 (0.0, 4.0),
1479 )
1480 .unwrap()
1481 .into();
1482 let patch = cone.to_bspline(T).unwrap();
1483 let exact = Cone::new(Frame::WORLD, 1.0, 0.4_f64.atan(), T).unwrap();
1484 let off = deviation(|p| exact.distance_to(p), &patch);
1485 assert!(off < 1e-12, "off the cone by {off}");
1486 assert!(spans_the_same(&cone, &patch));
1487 }
1488
1489 #[test]
1490 fn an_extrusion_becomes_its_profile_lofted() {
1491 let circle = crate::curve::CircleCurve::new(Circle::new(Frame::WORLD, 3.0, T).unwrap());
1492 let extrusion: SurfaceGeometry = ExtrusionSurface::new(circle.into(), Direction::Z, 6.0)
1493 .unwrap()
1494 .into();
1495 let patch = extrusion.to_bspline(T).unwrap();
1496 let exact = Cylinder::new(Frame::WORLD, 3.0, T).unwrap();
1499 let off = deviation(|p| exact.distance_to(p), &patch);
1500 assert!(off < 1e-12, "off the swept circle by {off}");
1501 assert!(spans_the_same(&extrusion, &patch));
1502 }
1503
1504 #[test]
1505 fn a_patch_converts_to_itself() {
1506 let cylinder: SurfaceGeometry =
1507 CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.0, T).unwrap(), (0.0, 1.0))
1508 .unwrap()
1509 .into();
1510 let patch = cylinder.to_bspline(T).unwrap();
1511 let again: SurfaceGeometry = patch.clone().into();
1512 let twice = again.to_bspline(T).unwrap();
1513 assert_eq!(patch, twice);
1514 }
1515
1516 #[test]
1521 fn a_fitted_surface_holds_its_tolerance_between_the_samples() {
1522 let basis: SurfaceGeometry =
1523 CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.5, T).unwrap(), (0.0, 5.0))
1524 .unwrap()
1525 .into();
1526 let offset = SurfaceGeometry::Offset(Box::new(
1527 crate::surface::OffsetSurface::new(basis, 0.5).unwrap(),
1528 ));
1529 let tolerance = 1e-4;
1530 let fitted = offset.fitted_bspline(tolerance, T).unwrap();
1531 assert!(fitted.met && fitted.error <= tolerance, "{}", fitted.error);
1532 let patch = fitted.curve;
1533 let ((u0, u1), (v0, v1)) = offset.domain();
1534 let (nu, nv) = (157, 131);
1535 let mut worst = 0.0_f64;
1536 for j in 0..=nv {
1537 let v = v0 + (v1 - v0) * f64::from(j) / f64::from(nv);
1538 for i in 0..=nu {
1539 let u = u0 + (u1 - u0) * f64::from(i) / f64::from(nu);
1540 let p = patch.point_at(u, v, T).unwrap();
1541 let radial = (p.x.hypot(p.y) - 2.0).abs();
1542 let across = p.distance(offset.point_at(u, v, T).unwrap());
1543 worst = worst.max(radial).max(across);
1544 }
1545 }
1546 assert!(
1547 worst <= tolerance,
1548 "the fit strays {worst} between its samples"
1549 );
1550 }
1551
1552 #[test]
1557 fn a_fitted_surface_states_no_less_than_its_widest_miss() {
1558 let drum: SurfaceGeometry =
1559 CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.5, T).unwrap(), (0.0, 5.0))
1560 .unwrap()
1561 .into();
1562 let n = 8_u32;
1563 let mut points = Vec::new();
1564 for i in 0..n {
1565 for j in 0..n {
1566 let (x, y) = (
1567 10.0 * f64::from(i) / f64::from(n - 1),
1568 10.0 * f64::from(j) / f64::from(n - 1),
1569 );
1570 points.push(Point::new(x, y, (0.8 * x).sin() * (0.56 * y).cos()));
1571 }
1572 }
1573 let mut knots = vec![0.0; 4];
1574 knots.extend((1..=4).map(|i| f64::from(i) / 5.0));
1575 knots.extend([1.0; 4]);
1576 let knots = ogeom_math::KnotVector::new(knots, 3).unwrap();
1577 let grid = ogeom_math::ControlGrid::new(points, 8, 8).unwrap();
1578 let wavy: SurfaceGeometry =
1579 crate::surface::BSplineSurface::new(knots.clone(), knots, &grid, T)
1580 .unwrap()
1581 .into();
1582 for basis in [drum, wavy] {
1583 let offset = SurfaceGeometry::Offset(Box::new(
1584 crate::surface::OffsetSurface::new(basis, 0.5).unwrap(),
1585 ));
1586 let fitted = offset.fitted_bspline(1e-4, T).unwrap();
1587 let ((u0, u1), (v0, v1)) = offset.domain();
1588 let n = 400;
1589 let mut worst = 0.0_f64;
1590 for j in 0..=n {
1591 let v = v0 + (v1 - v0) * f64::from(j) / f64::from(n);
1592 for i in 0..=n {
1593 let u = u0 + (u1 - u0) * f64::from(i) / f64::from(n);
1594 let p = fitted.curve.point_at(u, v, T).unwrap();
1595 worst = worst.max(p.distance(offset.point_at(u, v, T).unwrap()));
1596 }
1597 }
1598 assert!(
1599 worst <= fitted.error,
1600 "the fit misses by {worst:.4e} and states {:.4e}",
1601 fitted.error
1602 );
1603 }
1604 }
1605
1606 #[test]
1611 fn an_offset_sphere_fits_through_its_poles() {
1612 let sphere: SurfaceGeometry =
1613 SphereSurface::new(Sphere::new(Frame::WORLD, 2.0, T).unwrap()).into();
1614 let offset = SurfaceGeometry::Offset(Box::new(
1615 crate::surface::OffsetSurface::new(sphere, 0.5).unwrap(),
1616 ));
1617 let ((u0, u1), (v0, v1)) = offset.domain();
1618 assert!(offset.point_at(u0, v1, T).is_err(), "no normal at a pole");
1619 let fitted = offset.fitted_bspline(1e-4, T).unwrap();
1620 assert!(fitted.met, "stays {}", fitted.error);
1621 let n = 200;
1622 let mut worst = 0.0_f64;
1623 for j in 0..=n {
1624 let v = v0 + (v1 - v0) * f64::from(j) / f64::from(n);
1625 for i in 0..=n {
1626 let u = u0 + (u1 - u0) * f64::from(i) / f64::from(n);
1627 let p = fitted.curve.point_at(u, v, T).unwrap();
1628 worst = worst.max((p.to_vector().magnitude() - 2.5).abs());
1629 }
1630 }
1631 assert!(
1632 worst <= fitted.error,
1633 "the fit misses by {worst:.4e} and states {:.4e}",
1634 fitted.error
1635 );
1636 for v in [v0, v1] {
1637 let pole = Point::new(0.0, 0.0, 2.5 * v.signum());
1638 let p = fitted.curve.point_at(f64::midpoint(u0, u1), v, T).unwrap();
1639 assert!(p.distance(pole) <= fitted.error, "{p:?}");
1640 }
1641 }
1642
1643 #[test]
1647 fn an_offset_cone_through_its_apex_is_refused() {
1648 let cone = Cone::new(Frame::WORLD, 1.0, 0.5, T).unwrap();
1649 let apex = -1.0 / 0.5_f64.tan();
1650 let basis: SurfaceGeometry = ConeSurface::new(cone, (apex, 1.0)).unwrap().into();
1651 let offset = SurfaceGeometry::Offset(Box::new(
1652 crate::surface::OffsetSurface::new(basis, 0.25).unwrap(),
1653 ));
1654 let refused = offset.fitted_bspline(1e-4, T).unwrap_err();
1655 assert!(refused.to_string().contains("no limit"), "{refused}");
1656 }
1657
1658 #[test]
1659 fn a_sphere_becomes_an_exact_rational_patch() {
1660 let sphere = Sphere::new(Frame::WORLD, 2.5, T).unwrap();
1661 let surface: SurfaceGeometry = SphereSurface::new(sphere).into();
1662 let patch = surface.to_bspline(T).unwrap();
1663 assert!(patch.is_rational(), "a sphere needs weights");
1664 assert!(deviation(|p| sphere.distance_to(p), &patch) < 1e-12);
1665 assert!(spans_the_same(&surface, &patch));
1666 }
1667
1668 #[test]
1669 fn a_torus_becomes_an_exact_rational_patch() {
1670 let torus = Torus::new(Frame::WORLD, 3.0, 1.0, T).unwrap();
1671 let surface: SurfaceGeometry = TorusSurface::new(torus).into();
1672 let patch = surface.to_bspline(T).unwrap();
1673 assert!(patch.is_rational(), "a torus needs weights");
1674 assert!(deviation(|p| torus.distance_to(p), &patch) < 1e-12);
1675 assert!(spans_the_same(&surface, &patch));
1676 }
1677
1678 #[test]
1679 fn a_revolution_becomes_the_exact_patch_its_own_construction_is() {
1680 use crate::curve::LineCurve;
1685 let line =
1686 LineCurve::segment(Point::new(2.0, 0.0, 0.0), Point::new(2.0, 0.0, 5.0), T).unwrap();
1687 let surface: SurfaceGeometry = crate::surface::RevolutionSurface::new(
1688 line.into(),
1689 ogeom_math::Axis {
1690 location: Point::new(0.0, 0.0, 0.0),
1691 direction: ogeom_math::Direction::Z,
1692 },
1693 1.5 * core::f64::consts::PI,
1694 )
1695 .unwrap()
1696 .into();
1697 let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
1698 let patch = surface.to_bspline(T).unwrap();
1699 assert!(deviation(|p| cylinder.distance_to(p), &patch) < 1e-12);
1700 assert!(spans_the_same(&surface, &patch));
1701 }
1702
1703 #[test]
1704 fn a_trimmed_surface_converts_as_its_basis_over_the_window() {
1705 let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
1706 let trimmed: SurfaceGeometry = SurfaceGeometry::Trimmed(Box::new(
1707 TrimmedSurface::new(plane, (0.0, 1.0), (2.0, 5.0), T).unwrap(),
1708 ));
1709 let patch = trimmed.to_bspline(T).unwrap();
1710 let flat = Plane::new(Frame::WORLD);
1711 assert!(deviation(|p| flat.distance_to(p), &patch) < 1e-12);
1712 assert!(spans_the_same(&trimmed, &patch));
1713 }
1714
1715 #[test]
1716 fn a_trimmed_spline_converts_to_its_piece() {
1717 let plane: SurfaceGeometry =
1718 PlaneSurface::over(Plane::new(Frame::WORLD), (0.0, 4.0), (0.0, 4.0))
1719 .unwrap()
1720 .into();
1721 let spline: SurfaceGeometry = plane.to_bspline(T).unwrap().into();
1722 let trimmed: SurfaceGeometry = SurfaceGeometry::Trimmed(Box::new(
1723 TrimmedSurface::new(spline, (0.25, 0.5), (0.0, 0.75), T).unwrap(),
1724 ));
1725 let patch = trimmed.to_bspline(T).unwrap();
1726 assert!(spans_the_same(&trimmed, &patch));
1727 }
1728
1729 #[test]
1730 fn a_spline_segment_keeps_its_parameters() {
1731 let cylinder: SurfaceGeometry = CylinderSurface::new(
1732 ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap(),
1733 (0.0, 3.0),
1734 )
1735 .unwrap()
1736 .into();
1737 let whole = cylinder.to_bspline(T).unwrap();
1738 let ((ua, ub), (va, vb)) = whole.domain();
1739 let u = (ua + 0.2 * (ub - ua), ua + 0.7 * (ub - ua));
1740 let v = (va + 0.1 * (vb - va), va + 0.6 * (vb - va));
1741 let piece = whole.segment(u, v, T).unwrap();
1742 assert_eq!(piece.domain(), (u, v));
1743 for i in 0..=8 {
1744 for j in 0..=8 {
1745 let s = u.0 + (u.1 - u.0) * f64::from(i) / 8.0;
1746 let t = v.0 + (v.1 - v.0) * f64::from(j) / 8.0;
1747 let off = piece
1748 .point_at(s, t, T)
1749 .unwrap()
1750 .distance(whole.point_at(s, t, T).unwrap());
1751 assert!(off < 1e-12, "{off} off at ({s}, {t})");
1752 }
1753 }
1754 }
1755}
1756
1757impl Curve {
1760 pub fn general_transformed(
1782 &self,
1783 t: &ogeom_math::GeneralTransform,
1784 tol: Tolerances,
1785 ) -> OgeomResult<BSplineCurve> {
1786 let spline = self.to_bspline(tol)?;
1787 let moved: Vec<Weighted<Point>> = spline
1788 .control_points()
1789 .iter()
1790 .map(|c| {
1791 let position = c.point();
1797 Weighted {
1798 scaled: Point::from_vector(t.apply(position).to_vector() * c.weight),
1799 weight: c.weight,
1800 }
1801 })
1802 .collect();
1803 if moved
1804 .iter()
1805 .all(|c| c.point().is_equal(moved[0].point(), tol))
1806 {
1807 ogeom_bail!(
1808 Construction,
1809 "this transform collapses the curve to a point; it is singular \
1810 in the curve's own directions"
1811 );
1812 }
1813 BSplineCurve::rational(spline.knots().clone(), moved)
1814 }
1815}
1816
1817#[cfg(test)]
1818#[allow(clippy::unwrap_used)]
1819mod affine_tests {
1820 use super::*;
1821 use crate::curve::{CircleCurve, LineCurve};
1822 use ogeom_math::{Circle, GeneralTransform, Matrix3, Vector};
1823
1824 const T: Tolerances = Tolerances::millimetres();
1825
1826 #[test]
1827 fn an_uneven_scale_carries_a_circle_onto_the_ellipse_it_should() {
1828 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1832 let stretch = GeneralTransform::new(
1833 Matrix3::new([[3.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
1834 Vector::ZERO,
1835 );
1836 let moved = circle.general_transformed(&stretch, T).unwrap();
1837
1838 let (a, b) = moved.knots().domain();
1839 for i in 0..=400 {
1840 #[allow(clippy::cast_precision_loss)]
1841 let u = a + (b - a) * i as f64 / 400.0;
1842 let p = moved.point_at(u, T).unwrap();
1843 let on_ellipse = (p.x / 3.0).powi(2) + p.y.powi(2);
1844 assert!(
1845 (on_ellipse - 1.0).abs() < 1e-12,
1846 "at {u} the point {p:?} is not on the ellipse: {on_ellipse}"
1847 );
1848 }
1849 }
1850
1851 #[test]
1852 fn a_shear_is_exact_because_a_spline_is_an_affine_combination() {
1853 let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(2.0, 3.0, 0.0), T)
1857 .unwrap()
1858 .into();
1859 let shear = GeneralTransform::new(
1860 Matrix3::new([[1.0, 0.7, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
1861 Vector::new(1.0, 2.0, 3.0),
1862 );
1863 let moved = line.general_transformed(&shear, T).unwrap();
1864
1865 for i in 0..=50 {
1866 #[allow(clippy::cast_precision_loss)]
1867 let u = i as f64 / 50.0;
1868 let before =
1869 line.point_at(line.domain().0 + (line.domain().1 - line.domain().0) * u, T);
1870 let after = moved.point_at(u, T).unwrap();
1871 assert!(shear.apply(before.unwrap()).is_equal(after, T));
1872 }
1873 }
1874
1875 #[test]
1876 fn a_rational_curves_weights_survive_the_move() {
1877 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
1884 let shift = GeneralTransform::new(Matrix3::IDENTITY, Vector::new(10.0, -4.0, 6.0));
1885 let moved = circle.general_transformed(&shift, T).unwrap();
1886
1887 let centre = Point::new(10.0, -4.0, 6.0);
1888 let (a, b) = moved.knots().domain();
1889 for i in 0..=300 {
1890 #[allow(clippy::cast_precision_loss)]
1891 let u = a + (b - a) * i as f64 / 300.0;
1892 let p = moved.point_at(u, T).unwrap();
1893 assert!(
1894 (p.distance(centre) - 2.0).abs() < 1e-12,
1895 "at {u} the radius is {}",
1896 p.distance(centre)
1897 );
1898 }
1899 }
1900
1901 #[test]
1902 fn a_transform_that_collapses_the_curve_is_refused() {
1903 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1907 let squash = GeneralTransform::new(Matrix3::new([[0.0; 3]; 3]), Vector::ZERO);
1908 assert!(circle.general_transformed(&squash, T).is_err());
1909 }
1910}