1use ogeom_core::{OgeomError, OgeomResult, Tolerances, ogeom_bail};
11use ogeom_math::{KnotVector, Point};
12
13use super::{fit_surface_scattered_at, precise};
14use crate::project::{SurfaceSeeds, project_on_surface_from};
15use crate::traits::Surface as _;
16use crate::{BSplineSurface, SurfaceGeometry};
17
18const SEED_SAMPLES: usize = 16;
21
22const NORMAL_QUARTILE: f64 = 0.674_489_750_196_081_7;
25
26#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum Target {
29 Fixed,
31 Tolerance(f64),
34}
35
36#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct ScatteredOptions {
39 pub degree: usize,
41 pub spans: (usize, usize),
43 pub most: (usize, usize),
45 pub fairing: f64,
55 pub target: Target,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Ended {
62 Fixed,
64 Met,
66 Bounds,
69 Noise,
72 Strays,
76}
77
78#[derive(Debug, Clone, PartialEq)]
80pub struct ScatteredFit {
81 pub surface: BSplineSurface,
83 pub parameters: Vec<(f64, f64)>,
86 pub sigma: f64,
88 pub cross_sigma: f64,
95 pub worst: (usize, f64),
97 pub spans: (usize, usize),
99 pub ended: Ended,
101}
102
103impl ScatteredFit {
104 #[must_use]
106 pub fn met(&self) -> bool {
107 self.ended == Ended::Met
108 }
109}
110
111struct Round {
113 surface: BSplineSurface,
114 u_knots: KnotVector,
115 v_knots: KnotVector,
116 feet: Vec<(f64, f64)>,
118 distances: Vec<f64>,
119}
120
121impl Round {
122 fn square_sum(&self) -> f64 {
123 self.distances.iter().map(|d| d * d).sum()
124 }
125
126 fn controls(&self) -> usize {
127 self.u_knots.control_point_count() * self.v_knots.control_point_count()
128 }
129}
130
131pub fn fit_surface_scattered_over(
179 points: &[Point],
180 seed: &SurfaceGeometry,
181 options: &ScatteredOptions,
182 tol: Tolerances,
183) -> OgeomResult<ScatteredFit> {
184 let ScatteredOptions {
185 degree,
186 spans,
187 most,
188 fairing,
189 target,
190 } = *options;
191 if points.len() < 8 {
192 ogeom_bail!(
193 Construction,
194 "a seeded fit needs at least eight points, four to each half"
195 );
196 }
197 if let Some(p) = points
198 .iter()
199 .find(|p| !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite()))
200 {
201 ogeom_bail!(Construction, "the point {p:?} is not finite");
202 }
203 if degree == 0 {
204 ogeom_bail!(Construction, "a fit needs a degree of at least one");
205 }
206 if spans.0 == 0 || spans.1 == 0 || most.0 < spans.0 || most.1 < spans.1 {
207 ogeom_bail!(
208 Construction,
209 "{}x{} starting spans within {}x{} is not a span budget",
210 spans.0,
211 spans.1,
212 most.0,
213 most.1
214 );
215 }
216 if !fairing.is_finite() || fairing < 0.0 {
217 ogeom_bail!(
218 Construction,
219 "a fairing weight of {fairing} is not a weight"
220 );
221 }
222 let tolerance = match target {
223 Target::Fixed => None,
224 Target::Tolerance(t) => {
225 if !(t > 0.0 && t.is_finite()) {
226 ogeom_bail!(Construction, "a tolerance of {t} is not a distance");
227 }
228 Some(t)
229 }
230 };
231
232 let start = seeded_parameters(points, seed, tol)?;
233 let uniform = |count: usize| KnotVector::clamped_uniform(degree, count + degree);
234 let mut current = round(
235 points,
236 &start,
237 (uniform(spans.0)?, uniform(spans.1)?),
238 fairing,
239 tol,
240 )?;
241 let Some(tolerance) = tolerance else {
242 return finish(points, &start, current, Ended::Fixed, fairing, tol);
243 };
244
245 let judged = (degree + 1) * (degree + 1);
246 loop {
247 if current.distances.iter().all(|d| *d <= tolerance) {
248 return finish(points, &start, current, Ended::Met, fairing, tol);
249 }
250 let (cells, strays) = cell_residuals(¤t, tolerance);
251 let mut missed: Vec<(f64, (usize, usize), usize)> = cells
252 .iter()
253 .filter_map(|c| c.missed.map(|w| (w, c.at, c.count)))
254 .collect();
255 if missed.is_empty() && strays {
256 return finish(points, &start, current, Ended::Strays, fairing, tol);
257 }
258 missed.retain(|m| m.2 >= judged);
259 missed.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
260 let room = (
261 most.0 - span_count(¤t.u_knots),
262 most.1 - span_count(¤t.v_knots),
263 );
264 let Some(&(_, (i, j), _)) = missed.first() else {
265 return finish(points, &start, current, Ended::Bounds, fairing, tol);
266 };
267 let split_u: Vec<usize> = if room.0 > 0 { vec![i] } else { Vec::new() };
268 let split_v: Vec<usize> = if room.1 > 0 { vec![j] } else { Vec::new() };
269 if split_u.is_empty() && split_v.is_empty() {
270 return finish(points, &start, current, Ended::Bounds, fairing, tol);
271 }
272 let mut tries: Vec<(&[usize], &[usize])> = Vec::with_capacity(3);
273 if !split_u.is_empty() {
274 tries.push((&split_u, &[]));
275 }
276 if !split_v.is_empty() {
277 tries.push((&[], &split_v));
278 }
279 if !split_u.is_empty() && !split_v.is_empty() {
280 tries.push((&split_u, &split_v));
281 }
282 let variance = (median(¤t.distances) / NORMAL_QUARTILE).powi(2);
291 let score = |r: &Round| 2.0f64.mul_add(variance * precise(r.controls()), r.square_sum());
292 let mut best: Option<(f64, Round)> = None;
293 for (along_u, along_v) in tries {
294 let knots = (
295 split(¤t.u_knots, along_u)?,
296 split(¤t.v_knots, along_v)?,
297 );
298 let tried = round(points, &start, knots, fairing, tol)?;
299 let scored = score(&tried);
300 if best.as_ref().is_none_or(|(s, _)| scored < *s) {
301 best = Some((scored, tried));
302 }
303 }
304 match best {
305 Some((scored, next)) if scored < score(¤t) => current = next,
306 _ => return finish(points, &start, current, Ended::Noise, fairing, tol),
307 }
308 }
309}
310
311fn finish(
313 points: &[Point],
314 at: &[(f64, f64)],
315 round: Round,
316 ended: Ended,
317 fairing: f64,
318 tol: Tolerances,
319) -> OgeomResult<ScatteredFit> {
320 let cross_sigma = cross_sigma(points, at, &round, fairing, tol)?;
321 let spans = (span_count(&round.u_knots), span_count(&round.v_knots));
322 let sigma = (round.square_sum() / precise(round.distances.len())).sqrt();
323 let mut worst = (0, round.distances[0]);
324 for (i, &d) in round.distances.iter().enumerate() {
325 if d > worst.1 {
326 worst = (i, d);
327 }
328 }
329 Ok(ScatteredFit {
330 surface: round.surface,
331 parameters: round.feet,
332 sigma,
333 cross_sigma,
334 worst,
335 spans,
336 ended,
337 })
338}
339
340fn seeded_parameters(
343 points: &[Point],
344 seed: &SurfaceGeometry,
345 tol: Tolerances,
346) -> OgeomResult<Vec<(f64, f64)>> {
347 use ogeom_math::elementary;
348 let closed_form = |p: Point| -> Option<OgeomResult<(f64, f64)>> {
349 Some(match seed {
350 SurfaceGeometry::Plane(s) => Ok(elementary::plane_parameters(&s.plane(), p)),
351 SurfaceGeometry::Cylinder(s) => elementary::cylinder_parameters(&s.cylinder(), p, tol),
352 SurfaceGeometry::Cone(s) => elementary::cone_parameters(&s.cone(), p, tol),
353 SurfaceGeometry::Sphere(s) => elementary::sphere_parameters(&s.sphere(), p, tol),
354 SurfaceGeometry::Torus(s) => elementary::torus_parameters(&s.torus(), p, tol),
355 _ => return None,
356 })
357 };
358 let mut store: Option<SurfaceSeeds> = None;
359 let mut us = Vec::with_capacity(points.len());
360 let mut vs = Vec::with_capacity(points.len());
361 for &p in points {
362 let (u, v) = if let Some(found) = closed_form(p) {
363 found?
364 } else {
365 let store = match &mut store {
366 Some(store) => store,
367 empty => empty.insert(SurfaceSeeds::over(seed, SEED_SAMPLES, tol)?),
368 };
369 store.project(seed, p, tol)?.parameters
370 };
371 us.push(u);
372 vs.push(v);
373 }
374 let ((ua, ub), (va, vb)) = seed.domain();
375 let us = unit_range(us, seed.is_periodic_u().then_some(ub - ua), "u")?;
376 let vs = unit_range(vs, seed.is_periodic_v().then_some(vb - va), "v")?;
377 Ok(us.into_iter().zip(vs).collect())
378}
379
380fn unit_range(mut values: Vec<f64>, period: Option<f64>, name: &str) -> OgeomResult<Vec<f64>> {
383 if let Some(period) = period.filter(|p| *p > 0.0 && p.is_finite()) {
384 for t in &mut values {
385 *t = t.rem_euclid(period);
386 }
387 let mut sorted = values.clone();
388 sorted.sort_by(f64::total_cmp);
389 let mut cut = None;
392 let mut widest = sorted[0] + period - sorted[sorted.len() - 1];
393 for w in sorted.windows(2) {
394 if w[1] - w[0] > widest {
395 widest = w[1] - w[0];
396 cut = Some(w[1]);
397 }
398 }
399 if let Some(cut) = cut {
400 for t in &mut values {
401 if *t < cut {
402 *t += period;
403 }
404 }
405 }
406 }
407 let (lo, hi) = values
408 .iter()
409 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), t| {
410 (lo.min(*t), hi.max(*t))
411 });
412 if !(lo.is_finite() && hi.is_finite()) || hi <= lo {
413 ogeom_bail!(
414 Construction,
415 "the points do not spread along the seed's {name}; the seed does not unfold them"
416 );
417 }
418 let scaled: Vec<f64> = values
419 .iter()
420 .map(|t| ((t - lo) / (hi - lo)).clamp(0.0, 1.0))
421 .collect();
422 Ok(scaled)
423}
424
425fn round(
428 points: &[Point],
429 at: &[(f64, f64)],
430 (u_knots, v_knots): (KnotVector, KnotVector),
431 fairing: f64,
432 tol: Tolerances,
433) -> OgeomResult<Round> {
434 let surface = fit_surface_scattered_at(points, at, &u_knots, &v_knots, fairing, tol)?.curve;
435 let geometry = SurfaceGeometry::BSpline(surface.clone());
436 let mut feet = Vec::with_capacity(points.len());
437 let mut distances = Vec::with_capacity(points.len());
438 for (p, &guess) in points.iter().zip(at) {
439 let foot = project_on_surface_from(&geometry, *p, guess, tol)?;
440 feet.push(foot.parameters);
441 distances.push(foot.distance);
442 }
443 Ok(Round {
444 surface,
445 u_knots,
446 v_knots,
447 feet,
448 distances,
449 })
450}
451
452fn cross_sigma(
457 points: &[Point],
458 at: &[(f64, f64)],
459 round: &Round,
460 fairing: f64,
461 tol: Tolerances,
462) -> OgeomResult<f64> {
463 let mut sum = 0.0;
464 for held in 0..2 {
465 let (mut train, mut train_at) = (Vec::new(), Vec::new());
466 for (i, (p, a)) in points.iter().zip(at).enumerate() {
467 if i % 2 != held {
468 train.push(*p);
469 train_at.push(*a);
470 }
471 }
472 let fitted = match fit_surface_scattered_at(
473 &train,
474 &train_at,
475 &round.u_knots,
476 &round.v_knots,
477 fairing,
478 tol,
479 ) {
480 Ok(f) => f,
481 Err(OgeomError::Numeric(_)) => return Ok(f64::INFINITY),
482 Err(e) => return Err(e),
483 };
484 let geometry = SurfaceGeometry::BSpline(fitted.curve);
485 for (i, (p, a)) in points.iter().zip(at).enumerate() {
486 if i % 2 == held {
487 let d = project_on_surface_from(&geometry, *p, *a, tol)?.distance;
488 sum += d * d;
489 }
490 }
491 }
492 Ok((sum / precise(points.len())).sqrt())
493}
494
495struct Cell {
498 at: (usize, usize),
499 count: usize,
500 square_sum: f64,
501 missed: Option<f64>,
502}
503
504fn cell_residuals(round: &Round, tolerance: f64) -> (Vec<Cell>, bool) {
514 let (pu, pv) = (round.u_knots.degree(), round.v_knots.degree());
515 let (nu, nv) = (span_count(&round.u_knots), span_count(&round.v_knots));
516 let mut cells: Vec<Cell> = Vec::with_capacity(nu * nv);
517 for i in 0..nu {
518 for j in 0..nv {
519 cells.push(Cell {
520 at: (i, j),
521 count: 0,
522 square_sum: 0.0,
523 missed: None,
524 });
525 }
526 }
527 let index: Vec<usize> = round
528 .feet
529 .iter()
530 .map(|&(u, v)| {
531 let i = round.u_knots.span_unchecked(u) - pu;
532 let j = round.v_knots.span_unchecked(v) - pv;
533 i * nv + j
534 })
535 .collect();
536 for (&k, d) in index.iter().zip(&round.distances) {
537 cells[k].count += 1;
538 cells[k].square_sum += d * d;
539 }
540 let reach = (2.0 * precise(round.distances.len()).ln()).sqrt();
541 let mut strays = false;
542 for (&k, &d) in index.iter().zip(&round.distances) {
543 if d <= tolerance {
544 continue;
545 }
546 let cell = &mut cells[k];
547 let rest = cell.count - 1;
548 let stray =
549 rest > 0 && d > reach * (((cell.square_sum - d * d).max(0.0)) / precise(rest)).sqrt();
550 if stray {
551 strays = true;
552 } else if cell.missed.is_none_or(|w| d > w) {
553 cell.missed = Some(d);
554 }
555 }
556 cells.retain(|c| c.count > 0);
557 (cells, strays)
558}
559
560fn median(values: &[f64]) -> f64 {
563 let mut sorted = values.to_vec();
564 sorted.sort_by(f64::total_cmp);
565 let k = sorted.len() / 2;
566 if sorted.len().is_multiple_of(2) {
567 f64::midpoint(sorted[k - 1], sorted[k])
568 } else {
569 sorted[k]
570 }
571}
572
573fn span_count(knots: &KnotVector) -> usize {
575 knots.distinct().len() - 1
576}
577
578fn split(knots: &KnotVector, spans: &[usize]) -> OgeomResult<KnotVector> {
580 let distinct = knots.distinct();
581 let mut out = knots.clone();
582 for &s in spans {
583 out = out.with_knot_inserted(f64::midpoint(distinct[s].0, distinct[s + 1].0), 1)?;
584 }
585 Ok(out)
586}