1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
33use ogeom_math::{KnotVector, Point, Point2};
34
35use crate::curve::BSplineCurve;
36use crate::curve2d::BSpline2d;
37
38#[allow(
39 clippy::cast_precision_loss,
40 reason = "sample counts are far below 2^52"
41)]
42fn precise(n: usize) -> f64 {
43 n as f64
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub struct Fitted<C> {
49 pub curve: C,
51 pub error: f64,
54 pub met: bool,
61}
62
63pub fn fit_points(
75 points: &[Point],
76 degree: usize,
77 tolerance: f64,
78 tol: Tolerances,
79) -> OgeomResult<Fitted<BSplineCurve>> {
80 let (knots, control, error, met) = fit::<3>(
81 &points.iter().map(|p| [p.x, p.y, p.z]).collect::<Vec<_>>(),
82 degree,
83 tolerance,
84 false,
85 tol,
86 )?;
87 let curve = BSplineCurve::new(
88 knots,
89 control
90 .into_iter()
91 .map(|c| Point::new(c[0], c[1], c[2]))
92 .collect(),
93 tol,
94 )?;
95 Ok(Fitted { curve, error, met })
96}
97
98pub fn fit_points_faired(
116 points: &[Point],
117 degree: usize,
118 controls: usize,
119 smoothing: f64,
120 tol: Tolerances,
121) -> OgeomResult<Fitted<BSplineCurve>> {
122 if !smoothing.is_finite() || smoothing < 0.0 {
123 ogeom_bail!(
124 Construction,
125 "a smoothing weight of {smoothing} is not a weight"
126 );
127 }
128 if controls < degree + 1 {
129 ogeom_bail!(
130 Construction,
131 "{controls} control points cannot carry degree {degree}"
132 );
133 }
134 let m = points.len();
135 if m < 2 {
136 ogeom_bail!(Construction, "a fair curve needs at least two points");
137 }
138
139 let knots = KnotVector::clamped_uniform(degree, controls)?;
141 let (lo, hi) = (knots.domain_start(), knots.domain_end());
142 let mut cumulative = vec![0.0f64; m];
143 for i in 1..m {
144 cumulative[i] = cumulative[i - 1] + points[i].distance(points[i - 1]);
145 }
146 let total = cumulative[m - 1];
147 if total <= tol.confusion() {
148 ogeom_bail!(
149 Construction,
150 "the points coincide; there is no curve to fair"
151 );
152 }
153 let parameters: Vec<f64> = cumulative
154 .iter()
155 .map(|c| lo + (hi - lo) * (c / total))
156 .collect();
157
158 let n = controls;
161 let mut a = nalgebra::DMatrix::<f64>::zeros(m, n);
162 for (k, &u) in parameters.iter().enumerate() {
163 let span = knots.span_unchecked(u);
164 let basis = knots.basis(span, u);
165 let first = span - degree;
166 for (j, b) in basis.iter().enumerate() {
167 a[(k, first + j)] = *b;
168 }
169 }
170 let mut d = nalgebra::DMatrix::<f64>::zeros(n.saturating_sub(2), n);
171 for r in 0..n.saturating_sub(2) {
172 d[(r, r)] = 1.0;
173 d[(r, r + 1)] = -2.0;
174 d[(r, r + 2)] = 1.0;
175 }
176 let k_full = a.transpose() * &a + d.transpose() * &d * smoothing;
177
178 let free: Vec<usize> = (1..n - 1).collect();
179 let mut reduced = nalgebra::DMatrix::<f64>::zeros(free.len(), free.len());
180 for (ri, &i) in free.iter().enumerate() {
181 for (rj, &j) in free.iter().enumerate() {
182 reduced[(ri, rj)] = k_full[(i, j)];
183 }
184 }
185 let decomposition = reduced.lu();
186
187 let mut control = vec![Point::ORIGIN; n];
188 control[0] = points[0];
189 control[n - 1] = points[m - 1];
190 for axis in 0..3 {
191 let b_data =
192 nalgebra::DVector::from_iterator(m, points.iter().map(|p| [p.x, p.y, p.z][axis]));
193 let full_rhs = a.transpose() * &b_data;
194 let mut rhs = nalgebra::DVector::<f64>::zeros(free.len());
195 for (ri, &i) in free.iter().enumerate() {
196 rhs[ri] = full_rhs[i]
197 - k_full[(i, 0)] * [points[0].x, points[0].y, points[0].z][axis]
198 - k_full[(i, n - 1)] * [points[m - 1].x, points[m - 1].y, points[m - 1].z][axis];
199 }
200 let Some(solved) = decomposition.solve(&rhs) else {
201 ogeom_bail!(
202 Numeric,
203 "the faired system is singular; fewer controls or more points"
204 );
205 };
206 for (ri, &i) in free.iter().enumerate() {
207 match axis {
208 0 => control[i].x = solved[ri],
209 1 => control[i].y = solved[ri],
210 _ => control[i].z = solved[ri],
211 }
212 }
213 }
214
215 let curve = BSplineCurve::new(knots, control, tol)?;
216 let mut error = 0.0f64;
217 {
218 use crate::traits::Curve3d as _;
219 for (point, &u) in points.iter().zip(¶meters) {
220 error = error.max(curve.point_at(u, tol)?.distance(*point));
221 }
222 }
223 Ok(Fitted {
224 curve,
225 error,
226 met: true,
227 })
228}
229
230pub fn fit_points_closed(
248 points: &[Point],
249 degree: usize,
250 tolerance: f64,
251 tol: Tolerances,
252) -> OgeomResult<Fitted<BSplineCurve>> {
253 let Some((first, last)) = points.first().zip(points.last()) else {
254 ogeom_bail!(Construction, "a closed fit needs points");
255 };
256 if !first.is_equal(*last, tol) {
257 ogeom_bail!(
258 Construction,
259 "a closed fit needs a loop: the first point repeated at the end"
260 );
261 }
262 let (knots, control, error, met) = fit::<3>(
263 &points.iter().map(|p| [p.x, p.y, p.z]).collect::<Vec<_>>(),
264 degree,
265 tolerance,
266 true,
267 tol,
268 )?;
269 let curve = BSplineCurve::new(
270 knots,
271 control
272 .into_iter()
273 .map(|c| Point::new(c[0], c[1], c[2]))
274 .collect(),
275 tol,
276 )?;
277 Ok(Fitted { curve, error, met })
278}
279
280pub fn fit_points_2d(
289 points: &[Point2],
290 degree: usize,
291 tolerance: f64,
292 tol: Tolerances,
293) -> OgeomResult<Fitted<BSpline2d>> {
294 let (knots, control, error, met) = fit::<2>(
295 &points.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>(),
296 degree,
297 tolerance,
298 false,
299 tol,
300 )?;
301 let curve = BSpline2d::new(
302 knots,
303 control
304 .into_iter()
305 .map(|c| Point2::new(c[0], c[1]))
306 .collect(),
307 tol,
308 )?;
309 Ok(Fitted { curve, error, met })
310}
311
312#[allow(clippy::type_complexity)]
327pub fn fit_points_joint(
328 points: &[Point],
329 on_a: &[Point2],
330 on_b: &[Point2],
331 degree: usize,
332 tolerance: f64,
333 tol: Tolerances,
334) -> OgeomResult<(Fitted<BSplineCurve>, BSpline2d, BSpline2d)> {
335 fit_points_joint_inner(points, on_a, on_b, degree, tolerance, false, tol)
336}
337
338#[allow(clippy::type_complexity)]
351pub fn fit_points_joint_closed(
352 points: &[Point],
353 on_a: &[Point2],
354 on_b: &[Point2],
355 degree: usize,
356 tolerance: f64,
357 tol: Tolerances,
358) -> OgeomResult<(Fitted<BSplineCurve>, BSpline2d, BSpline2d)> {
359 fit_points_joint_inner(points, on_a, on_b, degree, tolerance, true, tol)
360}
361
362#[allow(clippy::type_complexity)]
363fn fit_points_joint_inner(
364 points: &[Point],
365 on_a: &[Point2],
366 on_b: &[Point2],
367 degree: usize,
368 tolerance: f64,
369 smooth_loop: bool,
370 tol: Tolerances,
371) -> OgeomResult<(Fitted<BSplineCurve>, BSpline2d, BSpline2d)> {
372 if points.len() != on_a.len() || points.len() != on_b.len() {
373 ogeom_bail!(
374 Construction,
375 "a joint fit needs the same trace seen in every space"
376 );
377 }
378 let joined: Vec<[f64; 7]> = points
379 .iter()
380 .zip(on_a)
381 .zip(on_b)
382 .map(|((p, a), b)| [p.x, p.y, p.z, a.x, a.y, b.x, b.y])
383 .collect();
384 let first = fit_spaced::<7>(
391 &joined,
392 degree,
393 tolerance,
394 smooth_loop,
395 Spacing::Centripetal,
396 tol,
397 )?;
398 let (knots, control, error, met) = if first.3 {
399 first
400 } else {
401 match fit_spaced::<7>(
402 &joined,
403 degree,
404 tolerance,
405 smooth_loop,
406 Spacing::ChordLength,
407 tol,
408 ) {
409 Ok(second) if second.2 < first.2 => second,
410 _ => first,
411 }
412 };
413 let curve = BSplineCurve::new(
414 knots.clone(),
415 control
416 .iter()
417 .map(|c| Point::new(c[0], c[1], c[2]))
418 .collect(),
419 tol,
420 )?;
421 let pa = BSpline2d::new(
422 knots.clone(),
423 control.iter().map(|c| Point2::new(c[3], c[4])).collect(),
424 tol,
425 )?;
426 let pb = BSpline2d::new(
427 knots,
428 control.iter().map(|c| Point2::new(c[5], c[6])).collect(),
429 tol,
430 )?;
431 Ok((Fitted { curve, error, met }, pa, pb))
432}
433
434pub fn fit_points_2d_at(
450 parameters: &[f64],
451 points: &[Point2],
452 degree: usize,
453 tolerance: f64,
454 tol: Tolerances,
455) -> OgeomResult<Fitted<BSpline2d>> {
456 fit_points_2d_at_inner(parameters, points, degree, tolerance, false, tol)
457}
458
459pub fn fit_points_at(
468 parameters: &[f64],
469 points: &[Point],
470 degree: usize,
471 tolerance: f64,
472 tol: Tolerances,
473) -> OgeomResult<Fitted<BSplineCurve>> {
474 if parameters.len() != points.len() {
475 ogeom_bail!(Construction, "one parameter per point, or the fit is a lie");
476 }
477 if parameters.windows(2).any(|w| w[1] <= w[0]) {
478 ogeom_bail!(Construction, "fixed parameters must strictly increase");
479 }
480 if !tolerance.is_finite() || tolerance <= 0.0 {
481 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
482 }
483 if degree == 0 {
484 ogeom_bail!(Construction, "a fit needs a degree of at least one");
485 }
486 let data: Vec<[f64; 3]> = points.iter().map(|p| [p.x, p.y, p.z]).collect();
487 if data.len() < 2 {
488 ogeom_bail!(Construction, "a fit needs at least two points");
489 }
490 let degree = degree.min(data.len() - 1);
491 let (a, b) = (parameters[0], parameters[parameters.len() - 1]);
492 let mut knots = single_span(degree, a, b)?;
493
494 const ROUNDS: usize = 32;
495 let mut best: Option<(KnotVector, Vec<[f64; 3]>, f64)> = None;
496 for _ in 0..ROUNDS {
497 let control = match least_squares::<3>(&knots, &data, parameters, false) {
498 Ok(control) => control,
499 Err(e) => {
500 if best.is_some() {
501 break;
502 }
503 return Err(e);
504 }
505 };
506 let errors = residuals::<3>(&knots, &control, &data, parameters);
507 let worst = errors.iter().fold(0.0_f64, |acc, e| acc.max(e.1));
508 if best.as_ref().is_none_or(|(_, _, held)| worst < *held) {
509 best = Some((knots.clone(), control.clone(), worst));
510 }
511 if worst <= tolerance {
512 let curve = build_curve_3(knots, control, tol)?;
513 return Ok(Fitted {
514 curve,
515 error: worst,
516 met: true,
517 });
518 }
519 if knots.control_point_count() >= data.len() {
520 break;
521 }
522 let Some(refined) = refined_where_bad(&knots, &errors, tolerance)? else {
523 break;
524 };
525 knots = refined;
526 }
527 let Some((knots, control, worst)) = best else {
528 ogeom_bail!(Construction, "the fit found no usable rounds");
529 };
530 let curve = build_curve_3(knots, control, tol)?;
531 Ok(Fitted {
532 curve,
533 error: worst,
534 met: false,
535 })
536}
537
538fn build_curve_3(
539 knots: KnotVector,
540 control: Vec<[f64; 3]>,
541 tol: Tolerances,
542) -> OgeomResult<BSplineCurve> {
543 BSplineCurve::new(
544 knots,
545 control
546 .into_iter()
547 .map(|c| Point::new(c[0], c[1], c[2]))
548 .collect(),
549 tol,
550 )
551}
552
553pub fn fit_points_2d_at_closed(
564 parameters: &[f64],
565 points: &[Point2],
566 degree: usize,
567 tolerance: f64,
568 tol: Tolerances,
569) -> OgeomResult<Fitted<BSpline2d>> {
570 fit_points_2d_at_inner(parameters, points, degree, tolerance, true, tol)
571}
572
573fn fit_points_2d_at_inner(
574 parameters: &[f64],
575 points: &[Point2],
576 degree: usize,
577 tolerance: f64,
578 closed: bool,
579 tol: Tolerances,
580) -> OgeomResult<Fitted<BSpline2d>> {
581 if parameters.len() != points.len() {
582 ogeom_bail!(Construction, "one parameter per point, or the fit is a lie");
583 }
584 if parameters.windows(2).any(|w| w[1] <= w[0]) {
585 ogeom_bail!(Construction, "fixed parameters must strictly increase");
586 }
587 if !tolerance.is_finite() || tolerance <= 0.0 {
588 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
589 }
590 if degree == 0 {
591 ogeom_bail!(Construction, "a fit needs a degree of at least one");
592 }
593 let data: Vec<[f64; 2]> = points.iter().map(|p| [p.x, p.y]).collect();
594 if data.len() < 2 {
595 ogeom_bail!(Construction, "a fit needs at least two points");
596 }
597 let degree = degree.min(data.len() - 1);
598 let (a, b) = (parameters[0], parameters[parameters.len() - 1]);
599 let mut knots = single_span(degree, a, b)?;
600
601 const ROUNDS: usize = 32;
602 let mut best: Option<(KnotVector, Vec<[f64; 2]>, f64)> = None;
603 for _ in 0..ROUNDS {
604 let control = match least_squares::<2>(&knots, &data, parameters, closed) {
608 Ok(control) => control,
609 Err(e) => {
610 if best.is_some() {
611 break;
612 }
613 return Err(e);
614 }
615 };
616 let errors = residuals::<2>(&knots, &control, &data, parameters);
617 let worst = errors.iter().fold(0.0_f64, |acc, e| acc.max(e.1));
618 if best.as_ref().is_none_or(|(_, _, held)| worst < *held) {
619 best = Some((knots.clone(), control.clone(), worst));
620 }
621 if worst <= tolerance {
622 let curve = BSpline2d::new(
623 knots,
624 control
625 .into_iter()
626 .map(|c| Point2::new(c[0], c[1]))
627 .collect(),
628 tol,
629 )?;
630 return Ok(Fitted {
631 curve,
632 error: worst,
633 met: true,
634 });
635 }
636 if knots.control_point_count() >= data.len() {
637 break;
638 }
639 let Some(refined) = refined_where_bad(&knots, &errors, tolerance)? else {
640 break;
641 };
642 knots = refined;
643 }
644 let (knots, control, error) = best.ok_or_else(|| {
645 ogeom_core::ogeom_err!(Construction, "the fixed-parameter fit never solved")
646 })?;
647 let curve = BSpline2d::new(
648 knots,
649 control
650 .into_iter()
651 .map(|c| Point2::new(c[0], c[1]))
652 .collect(),
653 tol,
654 )?;
655 Ok(Fitted {
656 curve,
657 error,
658 met: false,
659 })
660}
661
662#[allow(clippy::type_complexity)]
668#[derive(Clone, Copy, Debug, PartialEq, Eq)]
671enum Spacing {
672 Centripetal,
675 ChordLength,
683}
684
685fn fit<const D: usize>(
686 points: &[[f64; D]],
687 degree: usize,
688 tolerance: f64,
689 smooth_loop: bool,
690 tol: Tolerances,
691) -> OgeomResult<(KnotVector, Vec<[f64; D]>, f64, bool)> {
692 fit_spaced::<D>(
693 points,
694 degree,
695 tolerance,
696 smooth_loop,
697 Spacing::Centripetal,
698 tol,
699 )
700}
701
702fn fit_spaced<const D: usize>(
703 points: &[[f64; D]],
704 degree: usize,
705 tolerance: f64,
706 smooth_loop: bool,
707 spacing: Spacing,
708 tol: Tolerances,
709) -> OgeomResult<(KnotVector, Vec<[f64; D]>, f64, bool)> {
710 if !tolerance.is_finite() || tolerance <= 0.0 {
711 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
712 }
713 if degree == 0 {
714 ogeom_bail!(Construction, "a fit needs a degree of at least one");
715 }
716 let points = collapse::<D>(points, tol);
717 if points.len() < 2 {
718 ogeom_bail!(
719 Construction,
720 "a fit needs at least two distinct points, got {}",
721 points.len()
722 );
723 }
724 let degree = degree.min(points.len() - 1);
725 let closed =
730 smooth_loop && distance::<D>(&points[0], &points[points.len() - 1]) <= tol.confusion();
731 let parameters = match spacing {
732 Spacing::Centripetal => centripetal::<D>(&points),
733 Spacing::ChordLength => chord_length::<D>(&points),
734 };
735
736 let (a, b) = (parameters[0], parameters[parameters.len() - 1]);
739 let mut knots = single_span(degree, a, b)?;
740
741 const ROUNDS: usize = 32;
744 let mut parameters = parameters;
745 let mut best: Option<(KnotVector, Vec<[f64; D]>, f64)> = None;
746 for _ in 0..ROUNDS {
747 let control = match least_squares::<D>(&knots, &points, ¶meters, closed) {
752 Ok(control) => control,
753 Err(e) => {
754 if let Some((knots, control, worst)) = best {
755 return Ok((knots, control, worst, false));
756 }
757 return Err(e);
758 }
759 };
760 for _ in 0..2 {
770 correct_parameters::<D>(&knots, &control, &points, &mut parameters, closed);
771 }
772 let mut errors = residuals::<D>(&knots, &control, &points, ¶meters);
773 wandering::<D>(&knots, &control, &points, ¶meters, &mut errors);
774 let worst = errors.iter().fold(0.0_f64, |acc, e| acc.max(e.1));
775 if best.as_ref().is_none_or(|(_, _, held)| worst < *held) {
776 best = Some((knots.clone(), control.clone(), worst));
777 }
778 if worst <= tolerance {
779 return Ok((knots, control, worst, true));
780 }
781
782 if knots.control_point_count() >= points.len() + usize::from(closed) {
787 break;
788 }
789 let Some(refined) = refined_where_bad(&knots, &errors, tolerance)? else {
790 break;
791 };
792 knots = refined;
793 }
794
795 #[allow(clippy::unwrap_used, reason = "at least one round always runs")]
796 let (knots, control, worst) = best.unwrap();
797 Ok((knots, control, worst, false))
798}
799
800fn collapse<const D: usize>(points: &[[f64; D]], tol: Tolerances) -> Vec<[f64; D]> {
803 let mut out: Vec<[f64; D]> = Vec::with_capacity(points.len());
804 for p in points {
805 if out
806 .last()
807 .is_some_and(|q| distance::<D>(p, q) <= tol.confusion() * 0.01)
808 {
809 continue;
810 }
811 out.push(*p);
812 }
813 out
814}
815
816fn distance<const D: usize>(a: &[f64; D], b: &[f64; D]) -> f64 {
817 a.iter()
818 .zip(b)
819 .map(|(x, y)| (x - y) * (x - y))
820 .sum::<f64>()
821 .sqrt()
822}
823
824pub fn fit_surface_scattered(
844 points: &[Point],
845 degree: usize,
846 controls: (usize, usize),
847 smoothing: f64,
848 tol: Tolerances,
849) -> OgeomResult<Fitted<crate::BSplineSurface>> {
850 use crate::traits::Surface as _;
851 if !smoothing.is_finite() || smoothing < 0.0 {
852 ogeom_bail!(
853 Construction,
854 "a smoothing weight of {smoothing} is not a weight"
855 );
856 }
857 let (nu, nv) = controls;
858 if nu < degree + 1 || nv < degree + 1 {
859 ogeom_bail!(
860 Construction,
861 "{nu}x{nv} control points cannot carry degree {degree}"
862 );
863 }
864 let m = points.len();
865 if m < 4 {
866 ogeom_bail!(Construction, "a scattered fit needs at least four points");
867 }
868
869 let centroid = {
872 let mut sum = ogeom_math::Vector::ZERO;
873 for p in points {
874 sum += p.to_vector();
875 }
876 sum / precise(m)
877 };
878 let mut covariance = nalgebra::Matrix3::<f64>::zeros();
879 for p in points {
880 let d = p.to_vector() - centroid;
881 let v = nalgebra::Vector3::new(d.x, d.y, d.z);
882 covariance += v * v.transpose();
883 }
884 let eigen = nalgebra::SymmetricEigen::new(covariance);
885 let mut order: Vec<usize> = (0..3).collect();
886 order.sort_by(|a, b| {
887 eigen.eigenvalues[*b]
888 .partial_cmp(&eigen.eigenvalues[*a])
889 .unwrap_or(core::cmp::Ordering::Equal)
890 });
891 let axis = |i: usize| {
892 let c = eigen.eigenvectors.column(order[i]);
893 ogeom_math::Vector::new(c[0], c[1], c[2])
894 };
895 let (u_axis, v_axis) = (axis(0), axis(1));
896
897 let mut spans = Vec::with_capacity(m);
899 let (mut ulo, mut uhi) = (f64::INFINITY, f64::NEG_INFINITY);
900 let (mut vlo, mut vhi) = (f64::INFINITY, f64::NEG_INFINITY);
901 for p in points {
902 let d = p.to_vector() - centroid;
903 let (pu, pv) = (d.dot(u_axis), d.dot(v_axis));
904 ulo = ulo.min(pu);
905 uhi = uhi.max(pu);
906 vlo = vlo.min(pv);
907 vhi = vhi.max(pv);
908 spans.push((pu, pv));
909 }
910 if uhi - ulo <= tol.confusion() || vhi - vlo <= tol.confusion() {
911 ogeom_bail!(
912 Construction,
913 "the cloud is flat in a principal direction; fit a curve"
914 );
915 }
916 let u_knots = KnotVector::clamped_uniform(degree, nu)?;
917 let v_knots = KnotVector::clamped_uniform(degree, nv)?;
918 let (ka, kb) = (u_knots.domain_start(), u_knots.domain_end());
919 let (la, lb) = (v_knots.domain_start(), v_knots.domain_end());
920 let parameters: Vec<(f64, f64)> = spans
921 .iter()
922 .map(|(pu, pv)| {
923 (
924 ka + (kb - ka) * ((pu - ulo) / (uhi - ulo)),
925 la + (lb - la) * ((pv - vlo) / (vhi - vlo)),
926 )
927 })
928 .collect();
929
930 let n = nu * nv;
932 let mut a = nalgebra::DMatrix::<f64>::zeros(m, n);
933 for (k, &(pu, pv)) in parameters.iter().enumerate() {
934 let uspan = u_knots.span_unchecked(pu);
935 let vspan = v_knots.span_unchecked(pv);
936 let ub = u_knots.basis(uspan, pu);
937 let vb = v_knots.basis(vspan, pv);
938 let ufirst = uspan - degree;
939 let vfirst = vspan - degree;
940 for (j, bv) in vb.iter().enumerate() {
941 for (i, bu) in ub.iter().enumerate() {
942 a[(k, (ufirst + i) * nv + (vfirst + j))] = bu * bv;
943 }
944 }
945 }
946 let mut k_full = a.transpose() * &a;
947 let mut add_penalty = |along_u: bool| {
948 let (count_a, count_b) = if along_u { (nu, nv) } else { (nv, nu) };
951 for jb in 0..count_b {
952 for ia in 0..count_a.saturating_sub(2) {
953 let base = |offset: usize| -> usize {
954 if along_u {
955 (ia + offset) * nv + jb
956 } else {
957 jb * nv + ia + offset
958 }
959 };
960 let idx = [base(0), base(1), base(2)];
961 let w = [1.0, -2.0, 1.0];
962 for x in 0..3 {
963 for y in 0..3 {
964 k_full[(idx[x], idx[y])] += smoothing * w[x] * w[y];
965 }
966 }
967 }
968 }
969 };
970 add_penalty(true);
971 add_penalty(false);
972
973 let decomposition = k_full.clone().lu();
974 let mut control = vec![Point::ORIGIN; n];
975 for axis_i in 0..3 {
976 let b = nalgebra::DVector::from_iterator(m, points.iter().map(|p| [p.x, p.y, p.z][axis_i]));
977 let rhs = a.transpose() * &b;
978 let Some(solved) = decomposition.solve(&rhs) else {
979 ogeom_bail!(
980 Numeric,
981 "the scattered system is singular; raise the smoothing or lower the controls"
982 );
983 };
984 for (slot, value) in solved.iter().enumerate() {
985 match axis_i {
986 0 => control[slot].x = *value,
987 1 => control[slot].y = *value,
988 _ => control[slot].z = *value,
989 }
990 }
991 }
992
993 let grid = ogeom_math::ControlGrid::new(control, nu, nv)?;
994 let surface = crate::BSplineSurface::new(u_knots, v_knots, &grid, tol)?;
995 let mut error = 0.0f64;
996 for (p, &(pu, pv)) in points.iter().zip(¶meters) {
997 error = error.max(surface.point_at(pu, pv, tol)?.distance(*p));
998 }
999 Ok(Fitted {
1000 curve: surface,
1001 error,
1002 met: true,
1003 })
1004}
1005
1006pub fn fill_boundary(
1022 bottom: &crate::Curve,
1023 top: &crate::Curve,
1024 left: &crate::Curve,
1025 right: &crate::Curve,
1026 samples: usize,
1027 tolerance: f64,
1028 tol: Tolerances,
1029) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1030 use crate::traits::Curve3d as _;
1031 let samples = samples.max(4);
1032 let at = |curve: &crate::Curve, t: f64| -> OgeomResult<Point> {
1033 let (lo, hi) = curve.domain();
1034 curve.point_at(lo + (hi - lo) * t, tol)
1035 };
1036 let c00 = at(bottom, 0.0)?;
1038 let c10 = at(bottom, 1.0)?;
1039 let c01 = at(top, 0.0)?;
1040 let c11 = at(top, 1.0)?;
1041 let slack = tol.confusion() * 1e3;
1042 for (name, a, b) in [
1043 ("bottom-left", c00, at(left, 0.0)?),
1044 ("top-left", c01, at(left, 1.0)?),
1045 ("bottom-right", c10, at(right, 0.0)?),
1046 ("top-right", c11, at(right, 1.0)?),
1047 ] {
1048 if a.distance(b) > slack {
1049 ogeom_bail!(
1050 Construction,
1051 "the {name} corner does not close: the boundaries miss by {}",
1052 a.distance(b)
1053 );
1054 }
1055 }
1056
1057 let mut rows: Vec<Vec<Point>> = Vec::with_capacity(samples);
1058 for j in 0..samples {
1059 let v = precise(j) / precise(samples - 1);
1060 let mut row = Vec::with_capacity(samples);
1061 for i in 0..samples {
1062 let u = precise(i) / precise(samples - 1);
1063 let cu0 = at(bottom, u)?;
1066 let cu1 = at(top, u)?;
1067 let d0v = at(left, v)?;
1068 let d1v = at(right, v)?;
1069 let ruled_u = cu0.to_vector() * (1.0 - v) + cu1.to_vector() * v;
1070 let ruled_v = d0v.to_vector() * (1.0 - u) + d1v.to_vector() * u;
1071 let corners = c00.to_vector() * ((1.0 - u) * (1.0 - v))
1072 + c10.to_vector() * (u * (1.0 - v))
1073 + c01.to_vector() * ((1.0 - u) * v)
1074 + c11.to_vector() * (u * v);
1075 row.push(Point::from_vector(ruled_u + ruled_v - corners));
1076 }
1077 rows.push(row);
1078 }
1079 fit_surface_grid(&rows, 3, tolerance, tol)
1080}
1081
1082pub fn fit_surface_grid(
1102 rows: &[Vec<Point>],
1103 degree: usize,
1104 tolerance: f64,
1105 tol: Tolerances,
1106) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1107 fit_surface_grid_inner(rows, degree, tolerance, false, false, tol)
1108}
1109
1110pub fn fit_surface_grid_chordal(
1119 rows: &[Vec<Point>],
1120 degree: usize,
1121 tolerance: f64,
1122 tol: Tolerances,
1123) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1124 fit_surface_grid_inner(rows, degree, tolerance, false, true, tol)
1125}
1126
1127pub fn fit_surface_grid_closed_v(
1138 rows: &[Vec<Point>],
1139 degree: usize,
1140 tolerance: f64,
1141 tol: Tolerances,
1142) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1143 if rows.len() < 3 {
1144 ogeom_bail!(Construction, "a closed skin needs at least three rows");
1145 }
1146 let (first, last) = (&rows[0], &rows[rows.len() - 1]);
1147 if first.len() != last.len()
1148 || first
1149 .iter()
1150 .zip(last)
1151 .any(|(a, b)| a.distance(*b) > tol.confusion() * 100.0)
1152 {
1153 ogeom_bail!(
1154 Construction,
1155 "a closed skin needs a loop: the first row repeated at the end"
1156 );
1157 }
1158 fit_surface_grid_inner(rows, degree, tolerance, true, false, tol)
1159}
1160
1161pub fn fit_surface_grid_closed_v_chordal(
1172 rows: &[Vec<Point>],
1173 degree: usize,
1174 tolerance: f64,
1175 tol: Tolerances,
1176) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1177 if rows.len() < 3 {
1178 ogeom_bail!(Construction, "a closed skin needs at least three rows");
1179 }
1180 let (first, last) = (&rows[0], &rows[rows.len() - 1]);
1181 if first.len() != last.len()
1182 || first
1183 .iter()
1184 .zip(last)
1185 .any(|(a, b)| a.distance(*b) > tol.confusion() * 100.0)
1186 {
1187 ogeom_bail!(
1188 Construction,
1189 "a closed skin needs a loop: the first row repeated at the end"
1190 );
1191 }
1192 fit_surface_grid_inner(rows, degree, tolerance, true, true, tol)
1193}
1194
1195fn fit_surface_grid_inner(
1196 rows: &[Vec<Point>],
1197 degree: usize,
1198 tolerance: f64,
1199 closed_v: bool,
1200 by_chord: bool,
1201 tol: Tolerances,
1202) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1203 use crate::traits::Surface as _;
1204 if !tolerance.is_finite() || tolerance <= 0.0 {
1205 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
1206 }
1207 let nv = rows.len();
1208 if nv < 2 {
1209 ogeom_bail!(Construction, "a surface fit needs at least two rows");
1210 }
1211 let nu = rows[0].len();
1212 if nu < 2 || rows.iter().any(|r| r.len() != nu) {
1213 ogeom_bail!(Construction, "a surface fit needs a rectangular grid");
1214 }
1215 let raw: Vec<Vec<[f64; 3]>> = rows
1216 .iter()
1217 .map(|r| r.iter().map(|p| [p.x, p.y, p.z]).collect())
1218 .collect();
1219
1220 let average = |families: &[Vec<[f64; 3]>]| -> Vec<f64> {
1228 let mut sums = vec![0.0; families[0].len()];
1229 let mut counted = 0.0_f64;
1230 for family in families {
1231 let extent: f64 = family
1232 .windows(2)
1233 .map(|pair| distance::<3>(&pair[0], &pair[1]))
1234 .sum();
1235 if extent <= 0.0 {
1236 continue;
1237 }
1238 counted += 1.0;
1239 let assigned = if by_chord {
1240 chordal::<3>(family)
1241 } else {
1242 centripetal::<3>(family)
1243 };
1244 for (s, p) in sums.iter_mut().zip(assigned) {
1245 *s += p;
1246 }
1247 }
1248 if counted == 0.0 {
1249 let n = sums.len();
1251 return (0..n)
1252 .map(|i| {
1253 #[allow(clippy::cast_precision_loss)]
1254 let t = i as f64 / (n - 1).max(1) as f64;
1255 t
1256 })
1257 .collect();
1258 }
1259 sums.iter().map(|s| s / counted).collect()
1260 };
1261 let u_params = average(&raw);
1262 let columns: Vec<Vec<[f64; 3]>> = (0..nu)
1263 .map(|i| raw.iter().map(|r| r[i]).collect())
1264 .collect();
1265 let v_params = average(&columns);
1266
1267 let (u_knots, row_controls) = fit_family::<3>(&raw, &u_params, degree, tolerance * 0.5, false)?;
1269 let k = u_knots.control_point_count();
1271 let control_columns: Vec<Vec<[f64; 3]>> = (0..k)
1272 .map(|i| row_controls.iter().map(|r| r[i]).collect())
1273 .collect();
1274 let (v_knots, column_controls) = fit_family::<3>(
1275 &control_columns,
1276 &v_params,
1277 degree,
1278 tolerance * 0.5,
1279 closed_v,
1280 )?;
1281 let l = v_knots.control_point_count();
1282
1283 let mut net: Vec<Point> = Vec::with_capacity(k * l);
1286 for column in &column_controls {
1287 for c in column {
1288 net.push(Point::new(c[0], c[1], c[2]));
1289 }
1290 }
1291 let grid = ogeom_math::ControlGrid::new(net, k, l)?;
1292 let surface = crate::BSplineSurface::new(u_knots, v_knots, &grid, tol)?;
1293
1294 let mut worst = 0.0_f64;
1297 for (j, row) in rows.iter().enumerate() {
1298 for (i, p) in row.iter().enumerate() {
1299 let at = surface.point_at(u_params[i], v_params[j], tol)?;
1300 worst = worst.max(at.distance(*p));
1301 }
1302 }
1303 Ok(Fitted {
1304 curve: surface,
1305 error: worst,
1306 met: worst <= tolerance,
1307 })
1308}
1309
1310type FamilyRound<const D: usize> = (KnotVector, Vec<Vec<[f64; D]>>, f64);
1316
1317fn fit_family<const D: usize>(
1318 family: &[Vec<[f64; D]>],
1319 parameters: &[f64],
1320 degree: usize,
1321 tolerance: f64,
1322 closed: bool,
1323) -> OgeomResult<(KnotVector, Vec<Vec<[f64; D]>>)> {
1324 let degree = degree.min(parameters.len() - 1).max(1);
1325 let (a, b) = (parameters[0], parameters[parameters.len() - 1]);
1326 let mut knots = single_span(degree, a, b)?;
1327 const ROUNDS: usize = 24;
1328 let mut best: Option<FamilyRound<D>> = None;
1329 for _ in 0..ROUNDS {
1330 let mut controls = Vec::with_capacity(family.len());
1331 let mut merged: Vec<(f64, f64)> = parameters.iter().map(|u| (*u, 0.0)).collect();
1332 let mut solvable = true;
1333 for row in family {
1334 match least_squares::<D>(&knots, row, parameters, closed) {
1335 Ok(control) => {
1336 for (slot, entry) in residuals::<D>(&knots, &control, row, parameters)
1337 .iter()
1338 .zip(merged.iter_mut())
1339 {
1340 entry.1 = entry.1.max(slot.1);
1341 }
1342 controls.push(control);
1343 }
1344 Err(_) => {
1345 solvable = false;
1346 break;
1347 }
1348 }
1349 }
1350 if !solvable {
1351 break;
1352 }
1353 let worst = merged.iter().fold(0.0_f64, |acc, e| acc.max(e.1));
1354 if best.as_ref().is_none_or(|(_, _, held)| worst < *held) {
1355 best = Some((knots.clone(), controls, worst));
1356 }
1357 if worst <= tolerance
1358 || knots.control_point_count() >= parameters.len() + usize::from(closed)
1359 {
1360 break;
1361 }
1362 let Some(refined) = refined_where_bad(&knots, &merged, tolerance)? else {
1363 break;
1364 };
1365 knots = refined;
1366 }
1367 let Some((knots, controls, _)) = best else {
1368 ogeom_bail!(NotDone, "the family fit solved no round at all");
1369 };
1370 Ok((knots, controls))
1371}
1372
1373fn chordal<const D: usize>(points: &[[f64; D]]) -> Vec<f64> {
1380 let mut out = Vec::with_capacity(points.len());
1381 out.push(0.0);
1382 let mut total = 0.0;
1383 for pair in points.windows(2) {
1384 total += distance::<D>(&pair[0], &pair[1]);
1385 out.push(total);
1386 }
1387 if total > 0.0 {
1388 for u in &mut out {
1389 *u /= total;
1390 }
1391 }
1392 if let Some(last) = out.last_mut() {
1393 *last = 1.0;
1394 }
1395 out
1396}
1397
1398fn chord_length<const D: usize>(points: &[[f64; D]]) -> Vec<f64> {
1401 let mut out = Vec::with_capacity(points.len());
1402 out.push(0.0);
1403 let mut total = 0.0;
1404 for pair in points.windows(2) {
1405 total += distance::<D>(&pair[0], &pair[1]);
1406 out.push(total);
1407 }
1408 if total > 0.0 {
1409 for u in &mut out {
1410 *u /= total;
1411 }
1412 }
1413 if let Some(last) = out.last_mut() {
1414 *last = 1.0;
1415 }
1416 out
1417}
1418
1419fn centripetal<const D: usize>(points: &[[f64; D]]) -> Vec<f64> {
1420 let mut out = Vec::with_capacity(points.len());
1421 out.push(0.0);
1422 let mut total = 0.0;
1423 for pair in points.windows(2) {
1424 total += distance::<D>(&pair[0], &pair[1]).sqrt();
1425 out.push(total);
1426 }
1427 if total > 0.0 {
1428 for u in &mut out {
1429 *u /= total;
1430 }
1431 }
1432 if let Some(last) = out.last_mut() {
1435 *last = 1.0;
1436 }
1437 out
1438}
1439
1440fn single_span(degree: usize, a: f64, b: f64) -> OgeomResult<KnotVector> {
1442 let mut knots = Vec::with_capacity(2 * (degree + 1));
1443 knots.extend(core::iter::repeat_n(a, degree + 1));
1444 knots.extend(core::iter::repeat_n(b, degree + 1));
1445 KnotVector::new(knots, degree)
1446}
1447
1448fn least_squares<const D: usize>(
1454 knots: &KnotVector,
1455 points: &[[f64; D]],
1456 parameters: &[f64],
1457 closed: bool,
1458) -> OgeomResult<Vec<[f64; D]>> {
1459 let n = knots.control_point_count();
1460 let m = points.len();
1461 let degree = knots.degree();
1462
1463 let mut control = vec![[0.0; D]; n];
1464 control[0] = points[0];
1465 control[n - 1] = points[m - 1];
1466 if n <= 2 {
1467 return Ok(control);
1468 }
1469
1470 let t = knots.knots();
1478 let ratio = if closed && n >= 4 {
1479 let d_start = t[degree + 1] - t[1];
1480 let d_end = t[n + degree - 1] - t[n - 1];
1481 (d_start > 0.0 && d_end > 0.0).then(|| d_end / d_start)
1482 } else {
1483 None
1484 };
1485 let eliminated = ratio.map(|_| n - 2);
1486 let unknown_count = match eliminated {
1487 Some(_) => n - 3,
1488 None => n - 2,
1489 };
1490 if unknown_count == 0 {
1491 if let (Some(r), Some(e)) = (ratio, eliminated) {
1492 for d in 0..D {
1496 control[e][d] = r.mul_add(points[0][d], points[m - 1][d]) / (1.0 + r);
1497 }
1498 let _ = r;
1499 }
1500 return Ok(control);
1501 }
1502
1503 let mut normal = nalgebra::DMatrix::<f64>::zeros(unknown_count, unknown_count);
1505 let mut rhs = vec![nalgebra::DVector::<f64>::zeros(unknown_count); D];
1506
1507 let mut rows: Vec<(usize, Vec<(usize, f64)>)> = Vec::with_capacity(m);
1508 for (k, &u) in parameters.iter().enumerate() {
1509 let span = knots.span_unchecked(u);
1510 let basis = knots.basis(span, u);
1511 let first = span - degree;
1512 let mut row: Vec<(usize, f64)> = basis
1513 .iter()
1514 .enumerate()
1515 .map(|(j, b)| (first + j, *b))
1516 .collect();
1517 if let (Some(r), Some(e)) = (ratio, eliminated)
1521 && let Some(position) = row.iter().position(|(i, _)| *i == e)
1522 {
1523 let (_, b_e) = row.remove(position);
1524 row.push((usize::MAX, b_e * r));
1525 row.push((n - 1, b_e));
1526 row.push((1, -r * b_e));
1527 }
1528 rows.push((k, row));
1529 }
1530
1531 for (k, row) in &rows {
1532 let mut target = points[*k];
1535 for (index, b) in row {
1536 if *index == 0 || *index == usize::MAX {
1537 for d in 0..D {
1538 target[d] -= b * points[0][d];
1539 }
1540 } else if *index == n - 1 {
1541 for d in 0..D {
1542 target[d] -= b * points[m - 1][d];
1543 }
1544 }
1545 }
1546 let is_known = |i: usize| i == 0 || i == n - 1 || i == usize::MAX;
1547 for (i, bi) in row {
1548 if is_known(*i) {
1549 continue;
1550 }
1551 for (j, bj) in row {
1552 if is_known(*j) {
1553 continue;
1554 }
1555 normal[(i - 1, j - 1)] += bi * bj;
1556 }
1557 for d in 0..D {
1558 rhs[d][i - 1] += bi * target[d];
1559 }
1560 }
1561 }
1562
1563 let Some(inverted) = normal.clone().try_inverse() else {
1566 ogeom_bail!(
1567 NotDone,
1568 "the fitting system is singular: a knot span contains no data"
1569 );
1570 };
1571 for d in 0..D {
1572 let solved = &inverted * &rhs[d];
1573 for i in 0..unknown_count {
1574 control[i + 1][d] = solved[i];
1575 }
1576 }
1577 if let (Some(r), Some(e)) = (ratio, eliminated) {
1578 for d in 0..D {
1579 control[e][d] = points[m - 1][d] + r * (points[0][d] - control[1][d]);
1580 }
1581 }
1582 Ok(control)
1583}
1584
1585fn correct_parameters<const D: usize>(
1591 knots: &KnotVector,
1592 control: &[[f64; D]],
1593 points: &[[f64; D]],
1594 parameters: &mut [f64],
1595 looped: bool,
1596) {
1597 let (lo, hi) = knots.domain();
1598 let last = parameters.len() - 1;
1599 #[allow(clippy::cast_precision_loss)]
1607 let max_step = if looped {
1608 (hi - lo) * 8.0 / parameters.len() as f64
1609 } else {
1610 hi - lo
1611 };
1612 for (k, u) in parameters.iter_mut().enumerate() {
1613 if k == 0 || k == last {
1614 continue;
1615 }
1616 let (at, d1, d2) = evaluate::<D>(knots, control, *u);
1617 let gap: [f64; D] = core::array::from_fn(|d| at[d] - points[k][d]);
1618 let dot = |a: &[f64; D], b: &[f64; D]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f64>();
1619 let numerator = dot(&gap, &d1);
1620 let denominator = dot(&d1, &d1) + dot(&gap, &d2);
1621 if denominator.abs() <= f64::MIN_POSITIVE {
1622 continue;
1623 }
1624 let stepped = *u - (numerator / denominator).clamp(-max_step, max_step);
1625 if stepped.is_finite() {
1626 *u = stepped.clamp(lo, hi);
1627 }
1628 }
1629 for k in 1..parameters.len() {
1632 if parameters[k] < parameters[k - 1] {
1633 parameters[k] = parameters[k - 1];
1634 }
1635 }
1636}
1637
1638fn evaluate<const D: usize>(
1640 knots: &KnotVector,
1641 control: &[[f64; D]],
1642 u: f64,
1643) -> ([f64; D], [f64; D], [f64; D]) {
1644 let degree = knots.degree();
1645 let span = knots.span_unchecked(u);
1646 let table = knots.basis_derivatives(span, u, 2);
1647 let first = span - degree;
1648 let mut out = [[0.0; D]; 3];
1649 for (order, row) in table.iter().enumerate().take(3) {
1650 for (j, b) in row.iter().enumerate() {
1651 for d in 0..D {
1652 out[order][d] += b * control[first + j][d];
1653 }
1654 }
1655 }
1656 (out[0], out[1], out[2])
1657}
1658
1659fn residuals<const D: usize>(
1661 knots: &KnotVector,
1662 control: &[[f64; D]],
1663 points: &[[f64; D]],
1664 parameters: &[f64],
1665) -> Vec<(f64, f64)> {
1666 let degree = knots.degree();
1667 parameters
1668 .iter()
1669 .zip(points)
1670 .map(|(&u, p)| {
1671 let span = knots.span_unchecked(u);
1672 let basis = knots.basis(span, u);
1673 let first = span - degree;
1674 let mut at = [0.0; D];
1675 for (j, b) in basis.iter().enumerate() {
1676 for d in 0..D {
1677 at[d] += b * control[first + j][d];
1678 }
1679 }
1680 (u, distance::<D>(&at, p))
1681 })
1682 .collect()
1683}
1684
1685fn wandering<const D: usize>(
1694 knots: &KnotVector,
1695 control: &[[f64; D]],
1696 points: &[[f64; D]],
1697 parameters: &[f64],
1698 errors: &mut [(f64, f64)],
1699) {
1700 for k in 0..parameters.len().saturating_sub(1) {
1701 let middle = f64::midpoint(parameters[k], parameters[k + 1]);
1702 let (at, _, _) = evaluate::<D>(knots, control, middle);
1703 let chord: [f64; D] =
1704 core::array::from_fn(|d| f64::midpoint(points[k][d], points[k + 1][d]));
1705 let excess = distance::<D>(&at, &chord) - distance::<D>(&points[k], &points[k + 1]) / 2.0;
1706 if excess > 0.0 {
1707 errors[k].1 = errors[k].1.max(excess);
1708 errors[k + 1].1 = errors[k + 1].1.max(excess);
1709 }
1710 }
1711}
1712
1713fn refined_where_bad(
1725 knots: &KnotVector,
1726 errors: &[(f64, f64)],
1727 tolerance: f64,
1728) -> OgeomResult<Option<KnotVector>> {
1729 let distinct = knots.distinct();
1730 let mut refined = knots.clone();
1731 let mut changed = false;
1732 for window in distinct.windows(2) {
1733 let (lo, hi) = (window[0].0, window[1].0);
1734 let inside: Vec<f64> = errors
1735 .iter()
1736 .filter(|(u, _)| *u >= lo && *u < hi)
1737 .map(|(u, _)| *u)
1738 .collect();
1739 let bad = errors
1740 .iter()
1741 .any(|(u, e)| *u >= lo && *u < hi && *e > tolerance);
1742 if !bad || inside.len() < 2 {
1743 continue;
1744 }
1745 let at = f64::midpoint(inside[inside.len() / 2 - 1], inside[inside.len() / 2])
1747 .clamp(lo + (hi - lo) * 1e-6, hi - (hi - lo) * 1e-6);
1748 let left = inside.iter().any(|u| *u < at);
1750 let right = inside.iter().any(|u| *u >= at);
1751 if left && right {
1752 refined = refined.with_knot_inserted(at, 1)?;
1753 changed = true;
1754 }
1755 }
1756 Ok(if changed { Some(refined) } else { None })
1757}
1758
1759#[cfg(test)]
1760#[allow(clippy::unwrap_used)]
1761mod grid_tests {
1762 use super::*;
1763 use crate::traits::Surface as _;
1764 use ogeom_core::Tolerances;
1765
1766 const T: Tolerances = Tolerances::millimetres();
1767
1768 #[test]
1769 fn a_torus_patch_grid_fits_to_tolerance_on_and_off_the_grid() {
1770 let torus = ogeom_math::Torus::new(ogeom_math::Frame::WORLD, 2.0, 0.5, T).unwrap();
1771 let surface = crate::TorusSurface::new(torus);
1772 let (nu, nv) = (25, 17);
1773 let span_u = 1.2_f64;
1774 let span_v = 0.9_f64;
1775 let sample = |fu: f64, fv: f64| surface.point_at(span_u * fu, span_v * fv, T).unwrap();
1776 let mut rows = Vec::new();
1777 for j in 0..nv {
1778 let mut row = Vec::new();
1779 for i in 0..nu {
1780 row.push(sample(
1781 f64::from(i) / f64::from(nu - 1),
1782 f64::from(j) / f64::from(nv - 1),
1783 ));
1784 }
1785 rows.push(row);
1786 }
1787 let fitted = fit_surface_grid(&rows, 3, 1e-4, T).unwrap();
1788 assert!(fitted.met, "error {} above the target", fitted.error);
1789
1790 let (ud, vd) = fitted.curve.domain();
1794 for i in 0..8 {
1795 for j in 0..8 {
1796 let u = ud.0 + (ud.1 - ud.0) * (0.07 + 0.9 * f64::from(i) / 7.0);
1797 let v = vd.0 + (vd.1 - vd.0) * (0.07 + 0.9 * f64::from(j) / 7.0);
1798 let p = fitted.curve.point_at(u, v, T).unwrap();
1799 let d = torus.distance_to(p);
1800 assert!(d < 5e-4, "off-grid deviation {d} at ({u}, {v})");
1801 }
1802 }
1803 }
1804
1805 #[test]
1806 fn a_grid_the_basis_can_represent_fits_to_rounding() {
1807 let corner = |x: f64, y: f64| Point::new(x, y, 0.3 * x - 0.2 * y);
1809 let mut rows = Vec::new();
1810 for j in 0..6 {
1811 let mut row = Vec::new();
1812 for i in 0..6 {
1813 row.push(corner(f64::from(i), 2.0 * f64::from(j)));
1814 }
1815 rows.push(row);
1816 }
1817 let fitted = fit_surface_grid(&rows, 1, 1e-9, T).unwrap();
1818 assert!(fitted.met, "error {} above rounding", fitted.error);
1819 }
1820
1821 #[test]
1822 fn a_ragged_grid_is_refused() {
1823 let rows = vec![
1824 vec![Point::ORIGIN, Point::new(1.0, 0.0, 0.0)],
1825 vec![Point::new(0.0, 1.0, 0.0)],
1826 ];
1827 assert!(fit_surface_grid(&rows, 2, 1e-6, T).is_err());
1828 }
1829}
1830
1831#[cfg(test)]
1832#[allow(clippy::unwrap_used)]
1833mod tests {
1834
1835 #[test]
1836 fn a_v_closed_grid_fits_a_ring_with_a_smooth_join() {
1837 use crate::traits::Surface as _;
1838 let tau = core::f64::consts::TAU;
1841 let (major, minor) = (5.0_f64, 1.5_f64);
1842 let rows: Vec<Vec<Point>> = (0..=24)
1843 .map(|j| {
1844 let a = tau * f64::from(j) / 24.0;
1845 (0..=16)
1846 .map(|i| {
1847 let b = tau * f64::from(i) / 16.0;
1848 let r = minor.mul_add(b.cos(), major);
1849 Point::new(r * a.cos(), r * a.sin(), minor * b.sin())
1850 })
1851 .collect()
1852 })
1853 .collect();
1854 let fitted = fit_surface_grid_closed_v(&rows, 3, 5e-3, T).unwrap();
1855 assert!(fitted.met, "the ring fit should meet its tolerance");
1856 let surface = fitted.curve;
1857
1858 let grid = surface.grid();
1861 let (k, l) = (grid.u_count(), grid.v_count());
1862 for i in 0..k {
1863 let first = grid.points()[i * l].point();
1864 let last = grid.points()[i * l + (l - 1)].point();
1865 assert!(
1866 first.distance(last) < 1e-12,
1867 "border control rows differ at u-index {i}"
1868 );
1869 }
1870
1871 let (u_dom, v_dom) = surface.domain();
1873 for i in 0..5 {
1874 let u = u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(i) / 4.0;
1875 let (_, dv0) = surface.d1_at(u, v_dom.0, T).unwrap();
1876 let (_, dv1) = surface.d1_at(u, v_dom.1, T).unwrap();
1877 let gap = (dv0 / dv0.magnitude() - dv1 / dv1.magnitude()).magnitude();
1878 assert!(gap < 1e-6, "the join kinks at u = {u}: {gap}");
1879 }
1880 }
1881
1882 #[test]
1883 fn a_grid_that_is_not_a_loop_is_refused_by_the_closed_entry() {
1884 let rows: Vec<Vec<Point>> = (0..4)
1885 .map(|j| {
1886 (0..4)
1887 .map(|i| Point::new(f64::from(i), f64::from(j), 0.0))
1888 .collect()
1889 })
1890 .collect();
1891 assert!(fit_surface_grid_closed_v(&rows, 3, 1e-3, T).is_err());
1892 }
1893
1894 use super::*;
1895 use crate::traits::{Curve2d as _, Curve3d as _};
1896 use core::f64::consts::TAU;
1897
1898 #[test]
1902 fn a_curve_straying_between_its_samples_is_charged_for_it() {
1903 let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], 3).unwrap();
1904 let control = [[0.0, 0.0], [0.0, 10.0], [1.0, 10.0], [1.0, 0.0]];
1905 let points = [[0.0, 0.0], [1.0, 0.0]];
1906 let parameters = [0.0, 1.0];
1907 let mut errors = residuals::<2>(&knots, &control, &points, ¶meters);
1908 assert!(errors.iter().all(|e| e.1 < 1e-12));
1909 wandering::<2>(&knots, &control, &points, ¶meters, &mut errors);
1910 for e in &errors {
1911 assert!((e.1 - 7.0).abs() < 1e-12, "{e:?}");
1912 }
1913 }
1914
1915 #[test]
1916 fn scattered_points_fit_without_a_grid() {
1917 let mut state = 12345u64;
1921 let mut next = || {
1922 state = state
1923 .wrapping_mul(6364136223846793005)
1924 .wrapping_add(1442695040888963407);
1925 {
1926 #[allow(clippy::cast_precision_loss, reason = "31 bits fit exactly")]
1927 let r = (state >> 33) as f64;
1928 r / f64::from(u32::MAX) * 2.0 - 1.0
1929 }
1930 };
1931 let points: Vec<Point> = (0..200)
1932 .map(|_| {
1933 let (x, y) = (next() * 5.0, next() * 5.0);
1934 Point::new(x, y, 0.2 * (x * x + y * y))
1935 })
1936 .collect();
1937 let fitted = fit_surface_scattered(&points, 3, (8, 8), 1e-3, T).unwrap();
1938 assert!(
1939 fitted.error < 0.05,
1940 "the paraboloid fits to {}",
1941 fitted.error
1942 );
1943 }
1944
1945 #[test]
1946 fn fairing_trades_closeness_for_straightness_and_says_the_price() {
1947 let points: Vec<Point> = (0..15)
1950 .map(|i| {
1951 let t = f64::from(i);
1952 let kick = if i % 2 == 0 { 0.1 } else { -0.1 };
1953 Point::new(t, t + kick, 0.0)
1954 })
1955 .collect();
1956 let chased = fit_points_faired(&points, 3, 10, 0.0, T).unwrap();
1957 let faired = fit_points_faired(&points, 3, 10, 50.0, T).unwrap();
1958
1959 let bend = |c: &BSplineCurve| -> f64 {
1961 let pts = c.control_points();
1962 (1..pts.len() - 1)
1963 .map(|i| {
1964 let p0 = pts[i - 1].point();
1965 let p1 = pts[i].point();
1966 let p2 = pts[i + 1].point();
1967 ((p2 - p1) - (p1 - p0)).magnitude().powi(2)
1968 })
1969 .sum()
1970 };
1971 assert!(
1972 bend(&faired.curve) < bend(&chased.curve) / 4.0,
1973 "fairing must straighten: {} vs {}",
1974 bend(&faired.curve),
1975 bend(&chased.curve)
1976 );
1977 assert!(faired.error >= chased.error);
1981 use crate::traits::Curve3d as _;
1982 let (lo, hi) = faired.curve.domain();
1983 for i in 0..=32 {
1984 let u = lo + (hi - lo) * f64::from(i) / 32.0;
1985 let p = faired.curve.point_at(u, T).unwrap();
1986 let off_line = (p.y - p.x).abs() / core::f64::consts::SQRT_2;
1987 assert!(
1988 off_line < 0.12,
1989 "the batten stays in the noise band: {off_line}"
1990 );
1991 }
1992 assert!(faired.curve.point_at(lo, T).unwrap().distance(points[0]) < 1e-9);
1994 assert!(faired.curve.point_at(hi, T).unwrap().distance(points[14]) < 1e-9);
1995 }
1996
1997 const T: Tolerances = Tolerances::millimetres();
1998
1999 fn nearest(curve: &BSplineCurve, p: Point) -> f64 {
2001 let at = |u: f64| curve.point_at(u, T).map_or(f64::MAX, |q| p.distance(q));
2002 let mut best = (0.0, f64::MAX);
2003 for i in 0..=4000 {
2004 #[allow(clippy::cast_precision_loss)]
2005 let u = i as f64 / 4000.0;
2006 let d = at(u);
2007 if d < best.1 {
2008 best = (u, d);
2009 }
2010 }
2011 let (mut lo, mut hi) = ((best.0 - 5e-4).max(0.0), (best.0 + 5e-4).min(1.0));
2012 for _ in 0..100 {
2013 let one = lo + (hi - lo) / 3.0;
2014 let two = hi - (hi - lo) / 3.0;
2015 if at(one) < at(two) {
2016 hi = two;
2017 } else {
2018 lo = one;
2019 }
2020 }
2021 at(f64::midpoint(lo, hi)).min(best.1)
2022 }
2023
2024 fn circle_points(n: usize, radius: f64) -> Vec<Point> {
2027 (0..=n)
2028 .map(|i| {
2029 #[allow(clippy::cast_precision_loss)]
2030 let a = TAU * i as f64 / n as f64;
2031 Point::new(radius * a.cos(), radius * a.sin(), 0.0)
2032 })
2033 .collect()
2034 }
2035
2036 #[test]
2037 fn a_fit_meets_the_tolerance_it_was_asked_for_and_says_what_it_reached() {
2038 let points = circle_points(200, 5.0);
2039 for tolerance in [1e-2, 1e-4, 1e-6] {
2040 let fitted = fit_points(&points, 3, tolerance, T).unwrap();
2041 assert!(
2042 fitted.met,
2043 "target {tolerance:e} not met, got {:e}",
2044 fitted.error
2045 );
2046 assert!(
2047 fitted.error <= tolerance,
2048 "reported {:e} over target {tolerance:e}",
2049 fitted.error
2050 );
2051 let mut worst = 0.0_f64;
2057 for p in &points {
2058 worst = worst.max(nearest(&fitted.curve, *p));
2059 }
2060 assert!(
2061 worst <= tolerance * 1.5,
2062 "independent measurement found {worst:e} against {tolerance:e}"
2063 );
2064 }
2065 }
2066
2067 #[test]
2068 fn a_tighter_tolerance_never_uses_fewer_control_points() {
2069 let points = circle_points(300, 3.0);
2070 let coarse = fit_points(&points, 3, 1e-2, T).unwrap();
2071 let fine = fit_points(&points, 3, 1e-6, T).unwrap();
2072 assert!(
2073 fine.curve.control_points().len() > coarse.curve.control_points().len(),
2074 "{} then {}",
2075 coarse.curve.control_points().len(),
2076 fine.curve.control_points().len()
2077 );
2078 assert!(coarse.curve.control_points().len() < 30);
2081 }
2082
2083 #[test]
2084 fn knots_go_where_the_error_is() {
2085 let mut points = Vec::new();
2089 for i in 0..=100 {
2090 points.push(Point::new(f64::from(i) * 0.1, 0.0, 0.0));
2091 }
2092 for i in 1..=50 {
2093 let a = f64::from(i) / 50.0 * core::f64::consts::FRAC_PI_2;
2094 points.push(Point::new(10.0 + a.sin() * 0.5, (1.0 - a.cos()) * 0.5, 0.0));
2095 }
2096 for i in 1..=100 {
2097 points.push(Point::new(10.5, 0.5 + f64::from(i) * 0.1, 0.0));
2098 }
2099
2100 let fitted = fit_points(&points, 3, 1e-4, T).unwrap();
2101 assert!(fitted.met);
2102
2103 let distinct = fitted.curve.knots().distinct();
2106 let interior: Vec<f64> = distinct[1..distinct.len() - 1]
2107 .iter()
2108 .map(|(u, _)| *u)
2109 .collect();
2110 let near_corner = interior
2111 .iter()
2112 .filter(|u| (0.40..0.60).contains(*u))
2113 .count();
2114 assert!(
2115 near_corner * 2 > interior.len(),
2116 "only {near_corner} of {} interior knots are near the corner",
2117 interior.len()
2118 );
2119 }
2120
2121 #[test]
2122 fn the_ends_are_honoured_exactly_and_a_closed_loop_stays_closed() {
2123 let points = circle_points(64, 2.0);
2124 let fitted = fit_points(&points, 3, 1e-3, T).unwrap();
2125 let (a, b) = fitted.curve.knots().domain();
2126 let start = fitted.curve.point_at(a, T).unwrap();
2127 let end = fitted.curve.point_at(b, T).unwrap();
2128 assert!(start.is_equal(points[0], T), "the start drifted");
2129 assert!(end.is_equal(*points.last().unwrap(), T), "the end drifted");
2130 assert!(start.is_equal(end, T), "the loop opened");
2131 }
2132
2133 #[test]
2134 fn a_smooth_loop_closes_with_matching_tangents_at_the_join() {
2135 use crate::traits::Curve3d as _;
2136 let points = circle_points(128, 3.0);
2137 let fitted = fit_points_closed(&points, 3, 1e-4, T).unwrap();
2138 assert!(fitted.met, "target not met, reached {:e}", fitted.error);
2139
2140 let curve: crate::curve::Curve = fitted.curve.clone().into();
2141 let (a, b) = fitted.curve.knots().domain();
2142 let start = curve.point_at(a, T).unwrap();
2143 let end = curve.point_at(b, T).unwrap();
2144 assert!(start.is_equal(end, T), "the loop opened");
2145
2146 let out = curve.d1_at(a, T).unwrap();
2149 let back = curve.d1_at(b, T).unwrap();
2150 assert!(
2151 (out - back).magnitude() <= 1e-9 * out.magnitude(),
2152 "the join creases: {out:?} vs {back:?}"
2153 );
2154
2155 let open = fit_points(&points, 3, 1e-4, T).unwrap();
2158 let ocurve: crate::curve::Curve = open.curve.into();
2159 let (oa, ob) = (a, b);
2160 let _ = (ocurve.d1_at(oa, T).unwrap(), ocurve.d1_at(ob, T).unwrap());
2161 }
2162
2163 #[test]
2164 fn a_joint_closed_fit_is_smooth_in_all_seven_coordinates() {
2165 use crate::traits::{Curve2d as _, Curve3d as _};
2166 let n = 96;
2167 let mut points = Vec::new();
2168 let mut on_a = Vec::new();
2169 let mut on_b = Vec::new();
2170 for i in 0..=n {
2171 #[allow(clippy::cast_precision_loss)]
2172 let t = core::f64::consts::TAU * i as f64 / n as f64;
2173 points.push(Point::new(4.0 * t.cos(), 4.0 * t.sin(), 1.0));
2174 on_a.push(Point2::new(t.cos(), t.sin()));
2176 on_b.push(Point2::new(2.0 * t.sin(), t.cos() - 3.0));
2177 }
2178 let (space, pa, pb) = fit_points_joint_closed(&points, &on_a, &on_b, 3, 1e-4, T).unwrap();
2179 assert!(space.met, "reached {:e}", space.error);
2180
2181 let curve: crate::curve::Curve = space.curve.into();
2182 let (lo, hi) = curve.domain();
2183 let out3 = curve.d1_at(lo, T).unwrap();
2184 let back3 = curve.d1_at(hi, T).unwrap();
2185 assert!((out3 - back3).magnitude() <= 1e-9 * out3.magnitude());
2186 for plane in [&pa, &pb] {
2187 let planar: crate::curve2d::PlanarCurve = plane.clone().into();
2188 let out2 = planar.d1_at(lo, T).unwrap();
2189 let back2 = planar.d1_at(hi, T).unwrap();
2190 assert!(
2191 (out2 - back2).magnitude() <= 1e-9 * out2.magnitude(),
2192 "a pcurve creases at its seam"
2193 );
2194 }
2195 }
2196
2197 #[test]
2198 fn a_fit_that_is_not_a_loop_is_refused_by_the_closed_entry() {
2199 let mut points = circle_points(32, 1.0);
2200 points.pop();
2201 assert!(fit_points_closed(&points, 3, 1e-3, T).is_err());
2202 }
2203
2204 #[test]
2205 fn an_impossible_target_is_reported_not_rounded_up_to_success() {
2206 let points = vec![
2210 Point::new(0.0, 0.0, 0.0),
2211 Point::new(1.0, 1.0, 0.0),
2212 Point::new(2.0, -1.0, 0.0),
2213 Point::new(3.0, 1.0, 0.0),
2214 Point::new(4.0, 0.0, 0.0),
2215 ];
2216 let fitted = fit_points(&points, 3, 1e-15, T).unwrap();
2217 assert_eq!(fitted.met, fitted.error <= 1e-15);
2220 }
2221
2222 #[test]
2223 fn the_2d_fit_is_the_same_machinery() {
2224 let points: Vec<Point2> = (0..=100)
2225 .map(|i| {
2226 #[allow(clippy::cast_precision_loss)]
2227 let a = TAU * f64::from(i) / 100.0;
2228 Point2::new(3.0 * a.cos(), 3.0 * a.sin())
2229 })
2230 .collect();
2231 let fitted = fit_points_2d(&points, 3, 1e-4, T).unwrap();
2232 assert!(fitted.met);
2233 assert!(fitted.error <= 1e-4);
2234 for p in points.iter().step_by(17) {
2237 let scan = |u: f64| {
2238 fitted
2239 .curve
2240 .point_at(u, T)
2241 .map_or(f64::MAX, |q| p.distance(q))
2242 };
2243 let mut best = (0.0, f64::MAX);
2244 for i in 0..=2000 {
2245 #[allow(clippy::cast_precision_loss)]
2246 let u = i as f64 / 2000.0;
2247 let d = scan(u);
2248 if d < best.1 {
2249 best = (u, d);
2250 }
2251 }
2252 let (mut lo, mut hi) = ((best.0 - 1e-3).max(0.0), (best.0 + 1e-3).min(1.0));
2253 for _ in 0..100 {
2254 let one = lo + (hi - lo) / 3.0;
2255 let two = hi - (hi - lo) / 3.0;
2256 if scan(one) < scan(two) {
2257 hi = two;
2258 } else {
2259 lo = one;
2260 }
2261 }
2262 let found = scan(f64::midpoint(lo, hi)).min(best.1);
2263 assert!(found < 2e-4, "a 2d point is {found:e} off the fit");
2264 }
2265 }
2266
2267 #[test]
2268 fn inputs_that_describe_nothing_are_refused() {
2269 let p = Point::ORIGIN;
2270 assert!(fit_points(&[], 3, 1e-3, T).is_err());
2271 assert!(fit_points(&[p], 3, 1e-3, T).is_err());
2272 assert!(
2273 fit_points(&[p, p, p], 3, 1e-3, T).is_err(),
2274 "all duplicates"
2275 );
2276 let two = [p, Point::new(1.0, 0.0, 0.0)];
2277 assert!(fit_points(&two, 3, 0.0, T).is_err());
2278 assert!(fit_points(&two, 3, -1.0, T).is_err());
2279 assert!(fit_points(&two, 3, f64::NAN, T).is_err());
2280 assert!(fit_points(&two, 0, 1e-3, T).is_err());
2281 assert!(fit_points(&two, 3, 1e-9, T).unwrap().met);
2283 }
2284
2285 #[test]
2294 fn a_joint_fit_holds_a_straight_run_sampled_geometrically() {
2295 let mut points = Vec::new();
2296 let mut on_a = Vec::new();
2297 let mut on_b = Vec::new();
2298 let mut s = 0.0_f64;
2299 let mut step = 1e-5;
2300 while s < 16.0 {
2301 points.push(Point::new(-6.5, 9.0 - s, 8.0));
2302 on_a.push(Point2::new(-8.0 + s, -1.0));
2303 on_b.push(Point2::new(-0.2, 0.7 - s / 16.0));
2304 s += step;
2305 step = (step * 2.0).min(1.0);
2306 }
2307 points.push(Point::new(-6.5, -7.0, 8.0));
2308 on_a.push(Point2::new(8.0, -1.0));
2309 on_b.push(Point2::new(-0.2, -0.3));
2310 let (space, pa, pb) = fit_points_joint(&points, &on_a, &on_b, 3, 1e-6, T).unwrap();
2311 assert!(space.error < 1e-6, "the run is a line: {}", space.error);
2312 let (lo, hi) = space.curve.domain();
2313 for i in 0..=200 {
2314 let t = lo + (hi - lo) * f64::from(i) / 200.0;
2315 let p = space.curve.point_at(t, T).unwrap();
2316 assert!(
2317 (p.x + 6.5).abs() < 1e-6 && (p.z - 8.0).abs() < 1e-6,
2318 "off the line at {p:?}"
2319 );
2320 let a = pa.point_at(t, T).unwrap();
2321 assert!((a.y + 1.0).abs() < 1e-6 && (a.x - (-8.0 + (9.0 - p.y))).abs() < 1e-6);
2322 let b = pb.point_at(t, T).unwrap();
2323 assert!((b.x + 0.2).abs() < 1e-6);
2324 }
2325 }
2326}