1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
60use ogeom_geom::BSplineCurve;
61use ogeom_math::{KnotVector, Point};
62
63mod grow;
64mod primitive;
65pub(crate) mod segments;
66mod tails;
67pub mod zone;
68
69pub use grow::{AnyFit, Crease, FitKind, GrowOptions, Grown, Meeting, SeedReport, grow};
70pub use primitive::{
71 Fit, FitFixed, FitOptions, Side, circle2, circle3, cone, cylinder, line2, line3, plane, sphere,
72 torus,
73};
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub enum Spacing {
78 #[default]
84 Centripetal,
85 Chordal,
87 Uniform,
92}
93
94pub fn approximate_within(
109 points: &[Point],
110 tolerance: f64,
111 tol: Tolerances,
112) -> OgeomResult<ogeom_geom::fit::Fitted<BSplineCurve>> {
113 ogeom_geom::fit::fit_points(points, 3, tolerance, tol)
114}
115
116pub fn interpolate(
125 points: &[Point],
126 degree: usize,
127 spacing: Spacing,
128 tol: Tolerances,
129) -> OgeomResult<BSplineCurve> {
130 if degree == 0 {
131 ogeom_bail!(
132 Construction,
133 "a curve of degree zero is a point, not a curve"
134 );
135 }
136 if points.len() <= degree {
137 ogeom_bail!(
138 Construction,
139 "interpolating a degree-{degree} curve needs at least {} points, \
140 got {}",
141 degree + 1,
142 points.len()
143 );
144 }
145
146 let parameters = parameterize(points, spacing, tol)?;
147 interpolate_at(points, ¶meters, degree, tol)
148}
149
150pub(crate) fn spaced(points: &[Point], spacing: Spacing, tol: Tolerances) -> OgeomResult<Vec<f64>> {
152 parameterize(points, spacing, tol)
153}
154
155pub(crate) fn interpolate_at(
159 points: &[Point],
160 parameters: &[f64],
161 degree: usize,
162 tol: Tolerances,
163) -> OgeomResult<BSplineCurve> {
164 if points.len() <= degree || parameters.len() != points.len() {
165 ogeom_bail!(
166 Construction,
167 "interpolating a degree-{degree} curve needs more than {degree} points, one parameter each"
168 );
169 }
170 let knots = KnotVector::averaged(degree, parameters)?;
171
172 let n = points.len();
175 let mut rows: Vec<(usize, Vec<f64>)> = Vec::with_capacity(n);
176 for &t in parameters {
177 let span = knots.span(t, tol)?;
178 rows.push((span - degree, knots.basis(span, t).to_vec()));
179 }
180 let control = match solve_banded(&rows, points, degree) {
181 Some(control) => control,
182 None => {
183 let mut matrix = nalgebra::DMatrix::<f64>::zeros(n, n);
184 for (row, (first, basis)) in rows.iter().enumerate() {
185 for (j, value) in basis.iter().enumerate() {
186 matrix[(row, first + j)] = *value;
187 }
188 }
189 solve(&matrix, points)?
190 }
191 };
192 BSplineCurve::new(knots, control, tol)
193}
194
195fn solve_banded(rows: &[(usize, Vec<f64>)], rhs: &[Point], degree: usize) -> Option<Vec<Point>> {
202 let n = rows.len();
203 let width = 2 * degree + 1;
204 let mut band = vec![vec![0.0; width]; n];
205 for (i, (first, basis)) in rows.iter().enumerate() {
206 for (j, value) in basis.iter().enumerate() {
207 let column = first + j;
208 let offset = (column + degree).checked_sub(i)?;
209 if offset >= width {
210 return None;
211 }
212 band[i][offset] = *value;
213 }
214 }
215 let mut b: Vec<[f64; 3]> = rhs.iter().map(|p| [p.x, p.y, p.z]).collect();
216 for k in 0..n {
217 let pivot = band[k][degree];
218 if pivot.abs() <= f64::EPSILON {
219 return None;
220 }
221 for i in (k + 1)..n.min(k + degree + 1) {
222 let factor = band[i][k + degree - i] / pivot;
223 if factor == 0.0 {
224 continue;
225 }
226 for j in k..n.min(k + degree + 1) {
227 band[i][j + degree - i] -= factor * band[k][j + degree - k];
228 }
229 let row = b[k];
230 for (x, r) in b[i].iter_mut().zip(row) {
231 *x -= factor * r;
232 }
233 }
234 }
235 let mut x = vec![[0.0; 3]; n];
236 for k in (0..n).rev() {
237 let mut sum = b[k];
238 for j in (k + 1)..n.min(k + degree + 1) {
239 for (s, v) in sum.iter_mut().zip(x[j]) {
240 *s -= band[k][j + degree - k] * v;
241 }
242 }
243 x[k] = sum.map(|s| s / band[k][degree]);
244 }
245 Some(x.into_iter().map(|[a, b, c]| Point::new(a, b, c)).collect())
246}
247
248pub fn approximate(
261 points: &[Point],
262 degree: usize,
263 control_count: usize,
264 spacing: Spacing,
265 tol: Tolerances,
266) -> OgeomResult<BSplineCurve> {
267 if degree == 0 {
268 ogeom_bail!(
269 Construction,
270 "a curve of degree zero is a point, not a curve"
271 );
272 }
273 if control_count <= degree {
274 ogeom_bail!(
275 Construction,
276 "a degree-{degree} curve needs more than {degree} control points, \
277 asked for {control_count}"
278 );
279 }
280 if points.len() <= control_count {
281 ogeom_bail!(
282 Construction,
283 "approximating {} points with {control_count} control points is not \
284 an approximation: at that ratio the least-squares system *is* the \
285 interpolation system. Use `interpolate`",
286 points.len()
287 );
288 }
289
290 let parameters = parameterize(points, spacing, tol)?;
291 let knots = spread(degree, control_count, ¶meters)?;
296
297 let free = control_count - 2;
298 let inner = points.len() - 2;
299 let mut matrix = nalgebra::DMatrix::<f64>::zeros(inner, free);
300 let mut rhs = vec![Point::ORIGIN; inner];
301
302 for k in 1..points.len() - 1 {
303 let t = parameters[k];
304 let span = knots.span(t, tol)?;
305 let basis = knots.basis(span, t);
306
307 let mut residual = points[k].to_vector();
310 for (j, value) in basis.iter().enumerate() {
311 let column = span - degree + j;
312 if column == 0 {
313 residual -= points[0].to_vector() * *value;
314 } else if column == control_count - 1 {
315 residual -= points[points.len() - 1].to_vector() * *value;
316 } else {
317 matrix[(k - 1, column - 1)] = *value;
318 }
319 }
320 rhs[k - 1] = Point::ORIGIN + residual;
321 }
322
323 let normal = matrix.transpose() * &matrix;
327 let projected = project(&matrix, &rhs);
328 let middle = solve(&normal, &projected)?;
329
330 let mut control = Vec::with_capacity(control_count);
331 control.push(points[0]);
332 control.extend(middle);
333 control.push(points[points.len() - 1]);
334 BSplineCurve::new(knots, control, tol)
335}
336
337fn parameterize(points: &[Point], spacing: Spacing, tol: Tolerances) -> OgeomResult<Vec<f64>> {
339 let n = points.len();
340 if n < 2 {
341 ogeom_bail!(Construction, "fitting needs at least two points");
342 }
343 if spacing == Spacing::Uniform {
344 #[allow(clippy::cast_precision_loss)]
345 return Ok((0..n).map(|i| i as f64 / (n - 1) as f64).collect());
346 }
347
348 let mut weights = Vec::with_capacity(n - 1);
349 for w in points.windows(2) {
350 let chord = w[0].distance(w[1]);
351 if chord <= tol.confusion() {
352 ogeom_bail!(
353 Construction,
354 "two consecutive points coincide, which leaves a parameter \
355 interval of zero and a system with no solution; remove the \
356 duplicate before fitting"
357 );
358 }
359 weights.push(if spacing == Spacing::Centripetal {
360 chord.sqrt()
361 } else {
362 chord
363 });
364 }
365
366 let total: f64 = weights.iter().sum();
367 let mut parameters = Vec::with_capacity(n);
368 parameters.push(0.0);
369 let mut running = 0.0;
370 for w in &weights {
371 running += w;
372 parameters.push(running / total);
373 }
374 let last = parameters.len() - 1;
378 parameters[last] = 1.0;
379 Ok(parameters)
380}
381
382fn spread(degree: usize, control_count: usize, parameters: &[f64]) -> OgeomResult<KnotVector> {
388 let mut knots = vec![0.0; degree + 1];
389 let interior = control_count - degree - 1;
390
391 #[allow(clippy::cast_precision_loss)]
392 let step = (parameters.len() - 1) as f64 / (control_count - degree) as f64;
393 for j in 1..=interior {
394 #[allow(
395 clippy::cast_precision_loss,
396 clippy::cast_possible_truncation,
397 clippy::cast_sign_loss
398 )]
399 let at = (j as f64 * step) as usize;
400 #[allow(clippy::cast_precision_loss)]
401 let fraction = j as f64 * step - at as f64;
402 let a = parameters[at.min(parameters.len() - 1)];
403 let b = parameters[(at + 1).min(parameters.len() - 1)];
404 knots.push(fraction.mul_add(b - a, a));
405 }
406 knots.extend(std::iter::repeat_n(1.0, degree + 1));
407 KnotVector::new(knots, degree)
408}
409
410fn project(matrix: &nalgebra::DMatrix<f64>, rhs: &[Point]) -> Vec<Point> {
412 let mut out = vec![Point::ORIGIN; matrix.ncols()];
413 for (column, slot) in out.iter_mut().enumerate() {
414 let mut sum = ogeom_math::Vector::ZERO;
415 for (row, point) in rhs.iter().enumerate() {
416 sum += point.to_vector() * matrix[(row, column)];
417 }
418 *slot = Point::ORIGIN + sum;
419 }
420 out
421}
422
423fn solve(matrix: &nalgebra::DMatrix<f64>, rhs: &[Point]) -> OgeomResult<Vec<Point>> {
425 let n = rhs.len();
426 let mut b = nalgebra::DMatrix::<f64>::zeros(n, 3);
427 for (row, point) in rhs.iter().enumerate() {
428 b[(row, 0)] = point.x;
429 b[(row, 1)] = point.y;
430 b[(row, 2)] = point.z;
431 }
432
433 let Some(x) = matrix.clone().lu().solve(&b) else {
438 ogeom_bail!(
439 Construction,
440 "the fitting system has no unique solution; the points are \
441 degenerate, or the knots leave a span with no point in it"
442 );
443 };
444 Ok((0..n)
445 .map(|i| Point::new(x[(i, 0)], x[(i, 1)], x[(i, 2)]))
446 .collect())
447}
448
449#[cfg(test)]
450#[allow(clippy::unwrap_used, clippy::expect_used)]
451mod tests {
452 use super::*;
453 use ogeom_geom::Curve3d;
454
455 const T: Tolerances = Tolerances::millimetres();
456
457 fn helix(n: usize) -> Vec<Point> {
458 (0..n)
459 .map(|i| {
460 #[allow(clippy::cast_precision_loss)]
461 let t = i as f64 / (n - 1) as f64 * std::f64::consts::TAU;
462 Point::new(t.cos() * 5.0, t.sin() * 5.0, t * 0.5)
463 })
464 .collect()
465 }
466
467 #[test]
468 fn an_interpolant_passes_through_every_point() {
469 for degree in [2, 3, 5] {
472 let points = helix(12);
473 let curve = interpolate(&points, degree, Spacing::Centripetal, T).unwrap();
474 let parameters = parameterize(&points, Spacing::Centripetal, T).unwrap();
475
476 for (point, t) in points.iter().zip(¶meters) {
477 let on_curve = curve.point_at(*t, T).unwrap();
478 assert!(
479 on_curve.distance(*point) < 1e-9,
480 "degree {degree}: missed by {}",
481 on_curve.distance(*point)
482 );
483 }
484 }
485 }
486
487 #[test]
488 fn an_interpolant_through_collinear_points_is_the_line_they_lie_on() {
489 let points: Vec<Point> = (0..8).map(|i| Point::new(f64::from(i), 0.0, 0.0)).collect();
493 let curve = interpolate(&points, 3, Spacing::Centripetal, T).unwrap();
494
495 for i in 0..=40 {
496 let t = f64::from(i) / 40.0;
497 let p = curve.point_at(t, T).unwrap();
498 assert!(p.y.abs() < 1e-9 && p.z.abs() < 1e-9, "wandered to {p:?}");
499 }
500 }
501
502 #[test]
503 fn an_approximation_uses_the_control_points_it_was_given_and_hits_the_ends() {
504 let points = helix(60);
505 let curve = approximate(&points, 3, 10, Spacing::Centripetal, T).unwrap();
506 assert_eq!(curve.control_points().len(), 10);
507
508 let (a, b) = curve.domain();
509 assert!(curve.point_at(a, T).unwrap().distance(points[0]) < 1e-9);
510 assert!(
511 curve
512 .point_at(b, T)
513 .unwrap()
514 .distance(points[points.len() - 1])
515 < 1e-9
516 );
517 }
518
519 #[test]
520 fn more_control_points_fit_the_data_more_closely() {
521 let points = helix(80);
524 let parameters = parameterize(&points, Spacing::Centripetal, T).unwrap();
525 let mut previous = f64::INFINITY;
526
527 for count in [6, 10, 20, 40] {
528 let curve = approximate(&points, 3, count, Spacing::Centripetal, T).unwrap();
529 let worst = points
530 .iter()
531 .zip(¶meters)
532 .map(|(p, t)| curve.point_at(*t, T).unwrap().distance(*p))
533 .fold(0.0_f64, f64::max);
534 assert!(
535 worst < previous,
536 "{count} control points fit worse than the previous step: \
537 {worst} against {previous}"
538 );
539 previous = worst;
540 }
541 assert!(
542 previous < 0.05,
543 "40 control points should fit well, got {previous}"
544 );
545 }
546
547 #[test]
548 fn centripetal_spacing_beats_uniform_on_unevenly_spread_points() {
549 let mut points = vec![Point::ORIGIN];
553 for i in 1..=5 {
554 points.push(Point::new(f64::from(i) * 0.1, 0.0, 0.0));
555 }
556 points.push(Point::new(20.0, 0.0, 0.0));
557 points.push(Point::new(40.0, 0.0, 0.0));
558
559 let excursion = |spacing| {
560 let curve = interpolate(&points, 3, spacing, T).unwrap();
561 (0..=200)
562 .map(|i| {
563 let t = f64::from(i) / 200.0;
564 let p = curve.point_at(t, T).unwrap();
565 p.y.hypot(p.z)
566 })
567 .fold(0.0_f64, f64::max)
568 };
569 assert!(
570 excursion(Spacing::Centripetal) <= excursion(Spacing::Uniform) + 1e-12,
571 "centripetal should be no worse than uniform"
572 );
573 }
574
575 #[test]
576 fn asking_for_an_approximation_that_is_an_interpolation_is_refused() {
577 let points = helix(10);
581 let refused = approximate(&points, 3, 10, Spacing::Centripetal, T);
582 assert!(refused.is_err());
583 assert!(
584 format!("{}", refused.unwrap_err()).contains("Use `interpolate`"),
585 "the message should point at the function that does want this"
586 );
587 }
588
589 #[test]
590 fn coincident_points_are_refused_rather_than_solved_around() {
591 let points = vec![
595 Point::ORIGIN,
596 Point::new(1.0, 0.0, 0.0),
597 Point::new(1.0, 0.0, 0.0),
598 Point::new(2.0, 0.0, 0.0),
599 ];
600 let refused = interpolate(&points, 2, Spacing::Centripetal, T);
601 assert!(refused.is_err());
602 assert!(format!("{}", refused.unwrap_err()).contains("coincide"));
603 }
604
605 #[test]
606 fn too_few_points_for_the_degree_is_refused() {
607 let points = helix(3);
608 assert!(interpolate(&points, 5, Spacing::Centripetal, T).is_err());
609 assert!(interpolate(&points, 0, Spacing::Centripetal, T).is_err());
610 assert!(approximate(&points, 3, 3, Spacing::Centripetal, T).is_err());
611 }
612
613 #[test]
614 fn a_degree_one_interpolant_is_the_polyline_itself() {
615 let points = helix(6);
616 let curve = interpolate(&points, 1, Spacing::Chordal, T).unwrap();
617 assert_eq!(curve.control_points().len(), points.len());
618 for (control, point) in curve.control_points().iter().zip(&points) {
619 assert!(control.scaled.distance(*point) < 1e-12);
620 }
621 }
622}