Skip to main content

ogeom_geom/fit/
seeded.rs

1//! Scattered points fitted over a seed surface's chart.
2//!
3//! [`fit_surface_scattered`](super::fit_surface_scattered) reads its
4//! parameters off the cloud's principal plane, which folds anything that is
5//! not a height field over that plane. Here they come from a seed surface
6//! instead: each point's parameters are those of its nearest point on the
7//! seed, so a band round a cylinder, a cap on a sphere or a sheet over a
8//! previous fit unfolds the way the seed does.
9
10use 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
18/// The seeding grid's samples a direction, for a seed with neither a closed
19/// form nor span boxes: the count the projector's own callers use.
20const SEED_SAMPLES: usize = 16;
21
22/// The upper quartile of the standard normal distribution, `Φ⁻¹(3/4)`: the
23/// median of the absolute value of a normal deviate, in deviations.
24const NORMAL_QUARTILE: f64 = 0.674_489_750_196_081_7;
25
26/// How far [`fit_surface_scattered_over`] refines its control net.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum Target {
29    /// Fit once, over the starting spans.
30    Fixed,
31    /// Split the spans holding a point farther from the fit than this
32    /// distance, within the span bounds.
33    Tolerance(f64),
34}
35
36/// What [`fit_surface_scattered_over`] fits with.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct ScatteredOptions {
39    /// The degree in both directions.
40    pub degree: usize,
41    /// The knot spans to start from, `u` then `v`, evenly spaced.
42    pub spans: (usize, usize),
43    /// The most knot spans refinement may reach, `u` then `v`.
44    pub most: (usize, usize),
45    /// The thin-plate fairing weight, as in
46    /// [`fit_surface_scattered_at`](super::fit_surface_scattered_at), over
47    /// the unit square the fit's parameters run over.
48    ///
49    /// The energy pulls the fit towards a plane in those parameters, so it
50    /// bends a curved shape as well as settling the controls no point
51    /// reaches. A shape of extent `L` turning through `θ` radians has
52    /// second derivatives of order `L θ` over the square; a weight well
53    /// under `(tolerance / (L θ))²` leaves it alone.
54    pub fairing: f64,
55    /// Whether, and to what, the net is refined.
56    pub target: Target,
57}
58
59/// Why [`fit_surface_scattered_over`] stopped.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Ended {
62    /// [`Target::Fixed`]: there was nothing to refine to.
63    Fixed,
64    /// Every point is within the tolerance.
65    Met,
66    /// The spans that miss are at their bounds, or their cells hold too
67    /// few points to settle a split.
68    Bounds,
69    /// A split would fit the noise: none takes more squared distance off
70    /// than twice the noise's variance per control it adds.
71    Noise,
72    /// Only strays miss: every point beyond the tolerance stands farther
73    /// off than the noise its cell shows reaches, and a split would bend
74    /// the fit to it rather than follow a shape.
75    Strays,
76}
77
78/// What [`fit_surface_scattered_over`] produced.
79#[derive(Debug, Clone, PartialEq)]
80pub struct ScatteredFit {
81    /// The surface, over the unit square.
82    pub surface: BSplineSurface,
83    /// Each point's parameters: its nearest point on the surface, found
84    /// from where the seed put it. The distances reported are to these.
85    pub parameters: Vec<(f64, f64)>,
86    /// The root mean square distance from the points to the surface.
87    pub sigma: f64,
88    /// The root mean square distance from each point to a fit, over the
89    /// same knots and fairing, of the other half of the points (alternate
90    /// points, by index). A fit that follows the shape predicts the points
91    /// it did not see about as closely as those it did; a fit that follows
92    /// the noise predicts them worse, and this stands well above
93    /// [`sigma`](Self::sigma).
94    pub cross_sigma: f64,
95    /// The point farthest from the surface: its index and its distance.
96    pub worst: (usize, f64),
97    /// The knot spans, `u` then `v`.
98    pub spans: (usize, usize),
99    /// Why refinement stopped.
100    pub ended: Ended,
101}
102
103impl ScatteredFit {
104    /// Whether the tolerance was met.
105    #[must_use]
106    pub fn met(&self) -> bool {
107        self.ended == Ended::Met
108    }
109}
110
111/// A fit over given knots, its points at their feet on it.
112struct Round {
113    surface: BSplineSurface,
114    u_knots: KnotVector,
115    v_knots: KnotVector,
116    /// Each point's foot on it, and the distance there.
117    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
131/// Fit a surface to scattered points, parameterised over a seed surface.
132///
133/// Each point takes the parameters of its nearest point on `seed`: a
134/// closed form on a plane, cylinder, cone, sphere or torus (the whole
135/// surface, whatever window it carries), the projector's search on anything
136/// else, a previous fit included. A periodic direction is cut at the widest
137/// gap the points leave round it, so a band across the seed's seam stays in
138/// one piece; a cloud that runs all the way round is fitted open across
139/// that gap. The parameters are then scaled onto the unit square. The seed
140/// must unfold the points: a sphere seed's poles belong away from them,
141/// where longitude still tells points apart.
142///
143/// The net is fitted at those parameters by
144/// [`fit_surface_scattered_at`](super::fit_surface_scattered_at), faired as
145/// it fairs. Every distance reported is from a point to its nearest point
146/// on the fit, found from its parameters.
147///
148/// With [`Target::Tolerance`] the spans are then split where the fit
149/// misses. A cell (a `u` span by a `v` span) misses where one of its points
150/// stands farther from the fit than the tolerance and is not a stray, one
151/// standing farther than the noise of the rest of its cell (their root mean
152/// square distance) reaches over `m` points, `√(2 ln m)` times it
153/// ([`Ended::Strays`]). A cell is split only where it holds at least as
154/// many points as the `(degree + 1)²` controls that bear on it, fewer
155/// leaving its controls to the fairing.
156///
157/// Each round splits the worst-missed cell's `u` span, its `v` span, or
158/// both, at the middle, keeping the try with the least squared distances
159/// plus twice the noise's variance per control (Mallows' criterion, the
160/// variance estimated from the fit's median distance), so a shape that
161/// turns along one direction only gains spans along that one. A cell
162/// beside the worst can miss by the ringing of a fit that has not yet
163/// caught it, which a split at the worst takes out. Where no try scores
164/// below the fit so far, the split would fit noise rather than shape, and
165/// refinement ends there ([`Ended::Noise`]), as it does for a tolerance
166/// inside the noise's reach.
167///
168/// # Errors
169///
170/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
171/// there are fewer than eight points, a point is not finite, the degree is
172/// nought, a span count is nought or its bound below it, the fairing is not
173/// finite and non-negative, the tolerance is not a distance, a point has no
174/// nearest point on the seed (on a cylinder's axis, at a sphere's centre),
175/// or the points' parameters do not spread over both directions;
176/// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the whole
177/// cloud cannot settle the net (raise the fairing).
178pub 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(&current, 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(&current.u_knots),
262            most.1 - span_count(&current.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        // Mallows' criterion: a control fitted to noise alone takes the
283        // noise's variance off the squared distances on average, so of two
284        // fits the one whose squared distances plus twice that variance per
285        // control is less predicts unseen points better. The variance is
286        // read off the median distance, which the shape the fit has yet to
287        // follow moves only where it moves half the points: for a normal
288        // noise across the surface the median distance is `Φ⁻¹(3/4)` of
289        // its deviation.
290        let variance = (median(&current.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(&current.u_knots, along_u)?,
296                split(&current.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(&current) => current = next,
306            _ => return finish(points, &start, current, Ended::Noise, fairing, tol),
307        }
308    }
309}
310
311/// The result, read off a round fitted at `at`.
312fn 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
340/// Each point's parameters on the seed, a periodic direction cut at the
341/// points' widest gap round it, scaled onto the unit square.
342fn 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
380/// `values` onto `[0, 1]`: a periodic direction's first cut at the widest
381/// gap between them round the period, so they run in one piece.
382fn 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        // The gap round the end of the period first, so an even spread
390        // keeps the seed's own seam.
391        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
425/// The net fitted at `at` over the knots, each point's foot on it found
426/// from there.
427fn 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
452/// The root mean square distance from each point to the fit of the other
453/// half (alternate points by index) at `at`, over the round's knots. A half
454/// the net cannot be settled from predicts nothing, and reads as infinitely
455/// far.
456fn 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
495/// A cell's points: how many, and the farthest one beyond the tolerance
496/// that is not a stray.
497struct Cell {
498    at: (usize, usize),
499    count: usize,
500    square_sum: f64,
501    missed: Option<f64>,
502}
503
504/// The round's points binned by the knot cell their feet lie in, `u` span
505/// then `v` span, the empty cells left out, and whether any point beyond
506/// the tolerance is a stray.
507///
508/// A stray stands farther from the fit than noise as wide as the rest of
509/// its cell (their root mean square distance) reaches over the whole
510/// cloud: the largest of `m` normal deviates is under `√(2 ln m)` of their
511/// deviation, and the rest of a cell, its shape misses and all, measures
512/// that deviation from above.
513fn 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
560/// The median of non-empty `values`: the mean of the middle two of an even
561/// count.
562fn 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
573/// How many knot spans a vector's domain has.
574fn span_count(knots: &KnotVector) -> usize {
575    knots.distinct().len() - 1
576}
577
578/// The knot vector with a knot at the middle of each span listed.
579fn 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}