1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
37use ogeom_geom::BSplineCurve;
38use ogeom_math::{KnotVector, Point};
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum Spacing {
43 #[default]
49 Centripetal,
50 Chordal,
52 Uniform,
57}
58
59pub fn approximate_within(
74 points: &[Point],
75 tolerance: f64,
76 tol: Tolerances,
77) -> OgeomResult<ogeom_geom::fit::Fitted<BSplineCurve>> {
78 ogeom_geom::fit::fit_points(points, 3, tolerance, tol)
79}
80
81pub fn interpolate(
90 points: &[Point],
91 degree: usize,
92 spacing: Spacing,
93 tol: Tolerances,
94) -> OgeomResult<BSplineCurve> {
95 if degree == 0 {
96 ogeom_bail!(
97 Construction,
98 "a curve of degree zero is a point, not a curve"
99 );
100 }
101 if points.len() <= degree {
102 ogeom_bail!(
103 Construction,
104 "interpolating a degree-{degree} curve needs at least {} points, \
105 got {}",
106 degree + 1,
107 points.len()
108 );
109 }
110
111 let parameters = parameterize(points, spacing, tol)?;
112 interpolate_at(points, ¶meters, degree, tol)
113}
114
115pub(crate) fn spaced(points: &[Point], spacing: Spacing, tol: Tolerances) -> OgeomResult<Vec<f64>> {
117 parameterize(points, spacing, tol)
118}
119
120pub(crate) fn interpolate_at(
124 points: &[Point],
125 parameters: &[f64],
126 degree: usize,
127 tol: Tolerances,
128) -> OgeomResult<BSplineCurve> {
129 if points.len() <= degree || parameters.len() != points.len() {
130 ogeom_bail!(
131 Construction,
132 "interpolating a degree-{degree} curve needs more than {degree} points, one parameter each"
133 );
134 }
135 let knots = KnotVector::averaged(degree, parameters)?;
136
137 let n = points.len();
140 let mut rows: Vec<(usize, Vec<f64>)> = Vec::with_capacity(n);
141 for &t in parameters {
142 let span = knots.span(t, tol)?;
143 rows.push((span - degree, knots.basis(span, t).to_vec()));
144 }
145 let control = match solve_banded(&rows, points, degree) {
146 Some(control) => control,
147 None => {
148 let mut matrix = nalgebra::DMatrix::<f64>::zeros(n, n);
149 for (row, (first, basis)) in rows.iter().enumerate() {
150 for (j, value) in basis.iter().enumerate() {
151 matrix[(row, first + j)] = *value;
152 }
153 }
154 solve(&matrix, points)?
155 }
156 };
157 BSplineCurve::new(knots, control, tol)
158}
159
160fn solve_banded(rows: &[(usize, Vec<f64>)], rhs: &[Point], degree: usize) -> Option<Vec<Point>> {
167 let n = rows.len();
168 let width = 2 * degree + 1;
169 let mut band = vec![vec![0.0; width]; n];
170 for (i, (first, basis)) in rows.iter().enumerate() {
171 for (j, value) in basis.iter().enumerate() {
172 let column = first + j;
173 let offset = (column + degree).checked_sub(i)?;
174 if offset >= width {
175 return None;
176 }
177 band[i][offset] = *value;
178 }
179 }
180 let mut b: Vec<[f64; 3]> = rhs.iter().map(|p| [p.x, p.y, p.z]).collect();
181 for k in 0..n {
182 let pivot = band[k][degree];
183 if pivot.abs() <= f64::EPSILON {
184 return None;
185 }
186 for i in (k + 1)..n.min(k + degree + 1) {
187 let factor = band[i][k + degree - i] / pivot;
188 if factor == 0.0 {
189 continue;
190 }
191 for j in k..n.min(k + degree + 1) {
192 band[i][j + degree - i] -= factor * band[k][j + degree - k];
193 }
194 let row = b[k];
195 for (x, r) in b[i].iter_mut().zip(row) {
196 *x -= factor * r;
197 }
198 }
199 }
200 let mut x = vec![[0.0; 3]; n];
201 for k in (0..n).rev() {
202 let mut sum = b[k];
203 for j in (k + 1)..n.min(k + degree + 1) {
204 for (s, v) in sum.iter_mut().zip(x[j]) {
205 *s -= band[k][j + degree - k] * v;
206 }
207 }
208 x[k] = sum.map(|s| s / band[k][degree]);
209 }
210 Some(x.into_iter().map(|[a, b, c]| Point::new(a, b, c)).collect())
211}
212
213pub fn approximate(
226 points: &[Point],
227 degree: usize,
228 control_count: usize,
229 spacing: Spacing,
230 tol: Tolerances,
231) -> OgeomResult<BSplineCurve> {
232 if degree == 0 {
233 ogeom_bail!(
234 Construction,
235 "a curve of degree zero is a point, not a curve"
236 );
237 }
238 if control_count <= degree {
239 ogeom_bail!(
240 Construction,
241 "a degree-{degree} curve needs more than {degree} control points, \
242 asked for {control_count}"
243 );
244 }
245 if points.len() <= control_count {
246 ogeom_bail!(
247 Construction,
248 "approximating {} points with {control_count} control points is not \
249 an approximation: at that ratio the least-squares system *is* the \
250 interpolation system. Use `interpolate`",
251 points.len()
252 );
253 }
254
255 let parameters = parameterize(points, spacing, tol)?;
256 let knots = spread(degree, control_count, ¶meters)?;
261
262 let free = control_count - 2;
263 let inner = points.len() - 2;
264 let mut matrix = nalgebra::DMatrix::<f64>::zeros(inner, free);
265 let mut rhs = vec![Point::ORIGIN; inner];
266
267 for k in 1..points.len() - 1 {
268 let t = parameters[k];
269 let span = knots.span(t, tol)?;
270 let basis = knots.basis(span, t);
271
272 let mut residual = points[k].to_vector();
275 for (j, value) in basis.iter().enumerate() {
276 let column = span - degree + j;
277 if column == 0 {
278 residual -= points[0].to_vector() * *value;
279 } else if column == control_count - 1 {
280 residual -= points[points.len() - 1].to_vector() * *value;
281 } else {
282 matrix[(k - 1, column - 1)] = *value;
283 }
284 }
285 rhs[k - 1] = Point::ORIGIN + residual;
286 }
287
288 let normal = matrix.transpose() * &matrix;
292 let projected = project(&matrix, &rhs);
293 let middle = solve(&normal, &projected)?;
294
295 let mut control = Vec::with_capacity(control_count);
296 control.push(points[0]);
297 control.extend(middle);
298 control.push(points[points.len() - 1]);
299 BSplineCurve::new(knots, control, tol)
300}
301
302fn parameterize(points: &[Point], spacing: Spacing, tol: Tolerances) -> OgeomResult<Vec<f64>> {
304 let n = points.len();
305 if n < 2 {
306 ogeom_bail!(Construction, "fitting needs at least two points");
307 }
308 if spacing == Spacing::Uniform {
309 #[allow(clippy::cast_precision_loss)]
310 return Ok((0..n).map(|i| i as f64 / (n - 1) as f64).collect());
311 }
312
313 let mut weights = Vec::with_capacity(n - 1);
314 for w in points.windows(2) {
315 let chord = w[0].distance(w[1]);
316 if chord <= tol.confusion() {
317 ogeom_bail!(
318 Construction,
319 "two consecutive points coincide, which leaves a parameter \
320 interval of zero and a system with no solution; remove the \
321 duplicate before fitting"
322 );
323 }
324 weights.push(if spacing == Spacing::Centripetal {
325 chord.sqrt()
326 } else {
327 chord
328 });
329 }
330
331 let total: f64 = weights.iter().sum();
332 let mut parameters = Vec::with_capacity(n);
333 parameters.push(0.0);
334 let mut running = 0.0;
335 for w in &weights {
336 running += w;
337 parameters.push(running / total);
338 }
339 let last = parameters.len() - 1;
343 parameters[last] = 1.0;
344 Ok(parameters)
345}
346
347fn spread(degree: usize, control_count: usize, parameters: &[f64]) -> OgeomResult<KnotVector> {
353 let mut knots = vec![0.0; degree + 1];
354 let interior = control_count - degree - 1;
355
356 #[allow(clippy::cast_precision_loss)]
357 let step = (parameters.len() - 1) as f64 / (control_count - degree) as f64;
358 for j in 1..=interior {
359 #[allow(
360 clippy::cast_precision_loss,
361 clippy::cast_possible_truncation,
362 clippy::cast_sign_loss
363 )]
364 let at = (j as f64 * step) as usize;
365 #[allow(clippy::cast_precision_loss)]
366 let fraction = j as f64 * step - at as f64;
367 let a = parameters[at.min(parameters.len() - 1)];
368 let b = parameters[(at + 1).min(parameters.len() - 1)];
369 knots.push(fraction.mul_add(b - a, a));
370 }
371 knots.extend(std::iter::repeat_n(1.0, degree + 1));
372 KnotVector::new(knots, degree)
373}
374
375fn project(matrix: &nalgebra::DMatrix<f64>, rhs: &[Point]) -> Vec<Point> {
377 let mut out = vec![Point::ORIGIN; matrix.ncols()];
378 for (column, slot) in out.iter_mut().enumerate() {
379 let mut sum = ogeom_math::Vector::ZERO;
380 for (row, point) in rhs.iter().enumerate() {
381 sum += point.to_vector() * matrix[(row, column)];
382 }
383 *slot = Point::ORIGIN + sum;
384 }
385 out
386}
387
388fn solve(matrix: &nalgebra::DMatrix<f64>, rhs: &[Point]) -> OgeomResult<Vec<Point>> {
390 let n = rhs.len();
391 let mut b = nalgebra::DMatrix::<f64>::zeros(n, 3);
392 for (row, point) in rhs.iter().enumerate() {
393 b[(row, 0)] = point.x;
394 b[(row, 1)] = point.y;
395 b[(row, 2)] = point.z;
396 }
397
398 let Some(x) = matrix.clone().lu().solve(&b) else {
403 ogeom_bail!(
404 Construction,
405 "the fitting system has no unique solution; the points are \
406 degenerate, or the knots leave a span with no point in it"
407 );
408 };
409 Ok((0..n)
410 .map(|i| Point::new(x[(i, 0)], x[(i, 1)], x[(i, 2)]))
411 .collect())
412}
413
414#[cfg(test)]
415#[allow(clippy::unwrap_used, clippy::expect_used)]
416mod tests {
417 use super::*;
418 use ogeom_geom::Curve3d;
419
420 const T: Tolerances = Tolerances::millimetres();
421
422 fn helix(n: usize) -> Vec<Point> {
423 (0..n)
424 .map(|i| {
425 #[allow(clippy::cast_precision_loss)]
426 let t = i as f64 / (n - 1) as f64 * std::f64::consts::TAU;
427 Point::new(t.cos() * 5.0, t.sin() * 5.0, t * 0.5)
428 })
429 .collect()
430 }
431
432 #[test]
433 fn an_interpolant_passes_through_every_point() {
434 for degree in [2, 3, 5] {
437 let points = helix(12);
438 let curve = interpolate(&points, degree, Spacing::Centripetal, T).unwrap();
439 let parameters = parameterize(&points, Spacing::Centripetal, T).unwrap();
440
441 for (point, t) in points.iter().zip(¶meters) {
442 let on_curve = curve.point_at(*t, T).unwrap();
443 assert!(
444 on_curve.distance(*point) < 1e-9,
445 "degree {degree}: missed by {}",
446 on_curve.distance(*point)
447 );
448 }
449 }
450 }
451
452 #[test]
453 fn an_interpolant_through_collinear_points_is_the_line_they_lie_on() {
454 let points: Vec<Point> = (0..8).map(|i| Point::new(f64::from(i), 0.0, 0.0)).collect();
458 let curve = interpolate(&points, 3, Spacing::Centripetal, T).unwrap();
459
460 for i in 0..=40 {
461 let t = f64::from(i) / 40.0;
462 let p = curve.point_at(t, T).unwrap();
463 assert!(p.y.abs() < 1e-9 && p.z.abs() < 1e-9, "wandered to {p:?}");
464 }
465 }
466
467 #[test]
468 fn an_approximation_uses_the_control_points_it_was_given_and_hits_the_ends() {
469 let points = helix(60);
470 let curve = approximate(&points, 3, 10, Spacing::Centripetal, T).unwrap();
471 assert_eq!(curve.control_points().len(), 10);
472
473 let (a, b) = curve.domain();
474 assert!(curve.point_at(a, T).unwrap().distance(points[0]) < 1e-9);
475 assert!(
476 curve
477 .point_at(b, T)
478 .unwrap()
479 .distance(points[points.len() - 1])
480 < 1e-9
481 );
482 }
483
484 #[test]
485 fn more_control_points_fit_the_data_more_closely() {
486 let points = helix(80);
489 let parameters = parameterize(&points, Spacing::Centripetal, T).unwrap();
490 let mut previous = f64::INFINITY;
491
492 for count in [6, 10, 20, 40] {
493 let curve = approximate(&points, 3, count, Spacing::Centripetal, T).unwrap();
494 let worst = points
495 .iter()
496 .zip(¶meters)
497 .map(|(p, t)| curve.point_at(*t, T).unwrap().distance(*p))
498 .fold(0.0_f64, f64::max);
499 assert!(
500 worst < previous,
501 "{count} control points fit worse than the previous step: \
502 {worst} against {previous}"
503 );
504 previous = worst;
505 }
506 assert!(
507 previous < 0.05,
508 "40 control points should fit well, got {previous}"
509 );
510 }
511
512 #[test]
513 fn centripetal_spacing_beats_uniform_on_unevenly_spread_points() {
514 let mut points = vec![Point::ORIGIN];
518 for i in 1..=5 {
519 points.push(Point::new(f64::from(i) * 0.1, 0.0, 0.0));
520 }
521 points.push(Point::new(20.0, 0.0, 0.0));
522 points.push(Point::new(40.0, 0.0, 0.0));
523
524 let excursion = |spacing| {
525 let curve = interpolate(&points, 3, spacing, T).unwrap();
526 (0..=200)
527 .map(|i| {
528 let t = f64::from(i) / 200.0;
529 let p = curve.point_at(t, T).unwrap();
530 p.y.hypot(p.z)
531 })
532 .fold(0.0_f64, f64::max)
533 };
534 assert!(
535 excursion(Spacing::Centripetal) <= excursion(Spacing::Uniform) + 1e-12,
536 "centripetal should be no worse than uniform"
537 );
538 }
539
540 #[test]
541 fn asking_for_an_approximation_that_is_an_interpolation_is_refused() {
542 let points = helix(10);
546 let refused = approximate(&points, 3, 10, Spacing::Centripetal, T);
547 assert!(refused.is_err());
548 assert!(
549 format!("{}", refused.unwrap_err()).contains("Use `interpolate`"),
550 "the message should point at the function that does want this"
551 );
552 }
553
554 #[test]
555 fn coincident_points_are_refused_rather_than_solved_around() {
556 let points = vec![
560 Point::ORIGIN,
561 Point::new(1.0, 0.0, 0.0),
562 Point::new(1.0, 0.0, 0.0),
563 Point::new(2.0, 0.0, 0.0),
564 ];
565 let refused = interpolate(&points, 2, Spacing::Centripetal, T);
566 assert!(refused.is_err());
567 assert!(format!("{}", refused.unwrap_err()).contains("coincide"));
568 }
569
570 #[test]
571 fn too_few_points_for_the_degree_is_refused() {
572 let points = helix(3);
573 assert!(interpolate(&points, 5, Spacing::Centripetal, T).is_err());
574 assert!(interpolate(&points, 0, Spacing::Centripetal, T).is_err());
575 assert!(approximate(&points, 3, 3, Spacing::Centripetal, T).is_err());
576 }
577
578 #[test]
579 fn a_degree_one_interpolant_is_the_polyline_itself() {
580 let points = helix(6);
581 let curve = interpolate(&points, 1, Spacing::Chordal, T).unwrap();
582 assert_eq!(curve.control_points().len(), points.len());
583 for (control, point) in curve.control_points().iter().zip(&points) {
584 assert!(control.scaled.distance(*point) < 1e-12);
585 }
586 }
587}