Skip to main content

geop_core_geometry/nurb_surface/
offset_fit.rs

1//! The offset of a NURBS surface, as a NURBS surface that encloses it:
2//! fitted, and widened by a bound on how far the fit is from the offset
3//! that is proven over the whole domain, from the control points' hulls of
4//! polynomials, not sampled. Every surface the same way: a plane, a
5//! cylinder or a free-form blend.
6//!
7//! The offset `O = S + d N` keeps `S`'s parametrization. It is fitted as
8//! `S` plus a shift `D` in `S`'s own rational basis — first as it is, and
9//! where that cannot follow the offset, refined and raised to cubic: `D`'s
10//! homogeneous control points interpolate `W d N`, `W` the basis's weight,
11//! at the Greville points. `S + D` is then a NURBS surface with `S`'s
12//! weights, its control points `S`'s moved by `D`'s. Where `W N` is itself
13//! a polynomial of the basis — a plane's, a cylinder's, a cone's, a
14//! sphere's, a torus's — the fit is the offset, to the rounding, and the
15//! net keeps its degrees and its layout.
16//!
17//! **The bound.** What the fit misses is `E = D − d N`. Over one span
18//! `S = A / w` is a ratio of polynomials, `D = e / w`, and the normal is
19//! `N = m / |m|` for the polynomial field `m` of `net::normal_field`, a
20//! pole's factor divided out of it. Split `D = a N + b` with `b ⟂ N`:
21//!
22//! ```text
23//! |b|² = |e × m|² / (w² m·m),    a² − d² = (e·e − d² w²) / w² − |b|²,
24//! ```
25//!
26//! and where `a` has the sign of `d` (`d (e·m) > 0`), `|a − d| = |a² − d²|
27//! / |a + d| ≤ (|e·e − d² w²| / w² + |b|²) / |d|`. So `|E| ≤ |a − d| + |b|`
28//! is bounded by polynomials alone: each ratio over a box by the largest
29//! ratio of the Bernstein coefficients of its numerator and denominator,
30//! the sign of `e·m` by its coefficients' hull. Where the fit is exact,
31//! `e × m` and `e·e − d² w²` are zero as polynomials, and the bound is the
32//! rounding. A box whose bound is wider than
33//! [`OFFSET_DRIFT`] is split in four, and the hulls close in on the
34//! polynomials' ranges as the square of the box's size. Every control
35//! point is then widened by the largest bound over the boxes its basis
36//! function covers: the basis is non-negative and sums to one, so the
37//! surface is widened by at least the bound everywhere.
38//!
39//! **Where the offset is regular.** Along a principal direction the offset
40//! scales the surface by `1 − d κ`: regular where both factors are
41//! positive, collapsed where one of them is not, anywhere, and folded where
42//! that changes. Where it is nowhere regular — collapsed, or a factor as
43//! near zero as the boxes resolve, as offsetting a cylinder by its radius
44//! makes it a line — the offset is no surface either way. In the tangent
45//! basis `S_u, S_v` the factors are the eigenvalues of `I⁻¹ (I − d II)`, so regular means `I − d II` positive
46//! definite, and so `G = w⁴ (I − d II)`, `G_ab = P_a·P_b − d (w² / |m|)
47//! J_ab`, with the polynomials `P_a = w A_a − w_a A = w² S_a` and
48//! `J_ab = (w A_ab − w_ab A)·m`: its first entry and its determinant
49//! positive. `1 / |m|` is enclosed over a box by the hull of `m·m`, and
50//! each entry is a polynomial with an interval factor, whose sign is its
51//! coefficients': positive, negative, or undecided, and the box is split.
52//!
53//! At a pole `P_t` along the collapsed side vanishes with the factor, and
54//! the entries are divided by it, as `m` is: twice where `J_tt` vanishes
55//! twice too, a smooth pole like a sphere's, and once at an apex, a
56//! cone's, where the curvature round it grows as one over the distance.
57//! Along a side where the *offset* closes to a point (see `offset`), `G`
58//! has a null vector, the side's own direction: there the entries that
59//! vanish are taken by the mean value theorem, their derivative across the
60//! side, and the determinant divided by the distance from it.
61
62use geop_core_math::{
63    geop_error::{GeopError, GeopResult},
64    scalars::Scalar,
65    vector::{Vector, Vector3},
66};
67
68use super::{
69    NurbSurface3D,
70    net::{Net, Side, cross, dot, normal_field, poles, product, scale},
71    offset::Closing,
72};
73use crate::{nurb_curve::solve_interpolation_system, spline::breakpoints};
74
75/// How far a fitted offset may drift from the true offset before its spans
76/// are split finer, and how small each box's bound is made (see the module
77/// docs). It bounds effort and how wide the offset comes out, not whether
78/// it encloses the true one: what the fit misses is padded on, however
79/// much that is.
80const OFFSET_DRIFT: f64 = 1e-6;
81
82/// The most pieces each span of the surface is split into for the fit:
83/// the budget [`OFFSET_DRIFT`] is pursued with.
84const MAX_OFFSET_PIECES: usize = 32;
85
86/// The most boxes the bound is sought over, all spans together, before the
87/// offset is refused as not enclosed.
88const MAX_OFFSET_BOXES: usize = 1 << 14;
89
90/// How often a span's box is split in four before whether the offset is
91/// regular over it has to be answered: one still undecided then could
92/// fold. Bounds effort, not the answer: a box over which the offset is
93/// regular, or collapsed, everywhere is found so long before, unless a
94/// factor `1 − d κ` comes within the box's width of zero.
95const REGULAR_SPLITS: usize = 6;
96
97/// How many splits in a row that do not halve how far from zero a box's
98/// entries could be leave whether the offset is regular there undecided:
99/// effort, like [`REGULAR_SPLITS`]. One was too few: by a cone's closing
100/// side the entries narrow slowly at first, then decide.
101const STALLED_SPLITS: usize = 3;
102
103/// The degree in each direction of the polynomial fitted to `1 / |m|` over
104/// a box: its error falls as the box's size to this plus one.
105const INVERSE_DEGREE: usize = 3;
106
107/// How often a span's box is split in four while the bound on what the fit
108/// misses there is wider than [`OFFSET_DRIFT`]; then it is taken as it is.
109/// Bounds effort and how wide the offset may come out, not whether it
110/// encloses the true one.
111const BOUND_SPLITS: usize = 4;
112
113/// The polynomials over one span of the surface and of a shift fitted onto
114/// its offset (see the module docs): `S`'s weight `w`, the normal field
115/// `m`, a pole's factor divided out, and the shift's point part `e`,
116/// `D = e / w`.
117struct Fit {
118    w: Net<1>,
119    m: Net<3>,
120    e: Net<3>,
121}
122
123/// The polynomials over one span of the surface that tell where its offset
124/// is regular: `P_a·P_b` and `J_ab` for `uu`, `uv`, `vv`, each divided by a
125/// pole's factor as often as both vanish there, with `w` and `m`; and what
126/// `G_uv²` is multiplied by in the determinant where `G_tt` was divided by
127/// an apex's factor once only, not twice.
128struct Shape {
129    w: Net<1>,
130    m: Net<3>,
131    first: [Net<1>; 3],
132    second: [Net<1>; 3],
133    twist: Option<Net<1>>,
134}
135
136/// `net` divided `times` times by the factor vanishing along `side`; an
137/// error if it does not vanish there.
138fn divide<const D: usize>(net: &Net<D>, side: Side, times: usize) -> GeopResult<Net<D>> {
139    let mut out = net.clone();
140    for _ in 0..times {
141        out = out.divided(side)?.ok_or_else(|| {
142            GeopError::new(format!(
143                "a polynomial of the surface does not vanish along its pole at {side:?}"
144            ))
145        })?;
146    }
147    Ok(out)
148}
149
150/// The point part and the weight of the Bézier patch `patch`, centred,
151/// and its normal field with its poles' factors divided out, and those
152/// poles.
153fn parts(patch: &NurbSurface3D) -> GeopResult<(Net<3>, Net<1>, Net<3>, Vec<Side>)> {
154    let (a, w) = Net::of(patch, true)?.split();
155    let sides = poles(patch)?;
156    let mut m = normal_field(patch)?;
157    for &side in &sides {
158        m = divide(&m, side, 1)?;
159    }
160    Ok((a, w, m, sides))
161}
162
163/// `g ≈ 1 / |m|` over the box of `m` (see [`inverse_root`]), and the hull
164/// of `r = g² (m·m)`, definitely positive: `None` where either cannot be
165/// had over the box.
166fn inverse_length(m: &Net<3>) -> GeopResult<Option<(Net<1>, Scalar)>> {
167    let squared = m.product(m, dot)?;
168    let Some(g) = inverse_root(&squared)? else {
169        return Ok(None);
170    };
171    let r = g.product(&g, product)?.product(&squared, product)?.hull()[0];
172    Ok(r.definitely_greater(Scalar::ZERO).then_some((g, r)))
173}
174
175/// A polynomial of degree [`INVERSE_DEGREE`] over `squared`'s box that
176/// interpolates `1 / √squared` at a grid of its points, its coefficients
177/// sharp (a fit is a free choice; what it misses is bounded by the
178/// caller). `None` where `squared` could be zero at one of them.
179fn inverse_root(squared: &Net<1>) -> GeopResult<Option<Net<1>>> {
180    let n = INVERSE_DEGREE;
181    let grid = |(lo, hi): (Scalar, Scalar)| -> GeopResult<Vec<Scalar>> {
182        (0..=n)
183            .map(|k| match k {
184                // The ends as they are: a fraction of one past the end
185                // rounds out of the box.
186                0 => Ok(lo),
187                k if k == n => Ok(hi),
188                k => Ok(Scalar::fraction(
189                    lo,
190                    hi,
191                    Scalar::from_ratio(k as i64, n as i64)?,
192                )),
193            })
194            .collect()
195    };
196    let knots = |(lo, hi): (Scalar, Scalar)| {
197        let mut k = vec![lo; n + 1];
198        k.extend(vec![hi; n + 1]);
199        k
200    };
201    let (gu, gv) = (grid(squared.u)?, grid(squared.v)?);
202    let (ku, kv) = (knots(squared.u), knots(squared.v));
203    let mut rows = Vec::with_capacity(n + 1);
204    for &u in &gu {
205        let mut values = Vec::with_capacity(n + 1);
206        for &v in &gv {
207            let q = squared.value(u, v)?[0];
208            if !q.definitely_greater(Scalar::ZERO) {
209                return Ok(None);
210            }
211            values.push(Vector::from_array([Scalar::ONE.div(q.sqrt()?)?]));
212        }
213        rows.push(solve_interpolation_system(&gv, &kv, n, &values)?);
214    }
215    let mut c = vec![vec![Vector::zero(); n + 1]; n + 1];
216    for j in 0..=n {
217        let column: Vec<Vector<1>> = rows.iter().map(|row| row[j]).collect();
218        for (i, x) in solve_interpolation_system(&gu, &ku, n, &column)?
219            .into_iter()
220            .enumerate()
221        {
222            c[i][j] = Vector::from_array([x[0].sharpen()]);
223        }
224    }
225    Ok(Some(Net {
226        c,
227        u: squared.u,
228        v: squared.v,
229    }))
230}
231
232/// The factor `t` (`1 − t`) that vanishes along `side`, over the box of
233/// `like`, as a polynomial of degree one across it.
234fn side_factor(side: Side, like: &Net<1>) -> Net<1> {
235    let one = Vector::from_array([Scalar::ONE]);
236    let zero = Vector::from_array([Scalar::ZERO]);
237    let across = if side.at_end {
238        vec![one, zero]
239    } else {
240        vec![zero, one]
241    };
242    let c = if side.u_fixed {
243        across.into_iter().map(|x| vec![x]).collect()
244    } else {
245        vec![across]
246    };
247    Net {
248        c,
249        u: like.u,
250        v: like.v,
251    }
252}
253
254impl Fit {
255    fn new(surface: &NurbSurface3D, shift: &NurbSurface3D) -> GeopResult<Self> {
256        let (_, w, m, _) = parts(surface)?;
257        let (e, _) = Net::of(shift, false)?.split();
258        Ok(Fit { w, m, e })
259    }
260
261    /// What the fit misses over the box `u` × `v` (see the module docs):
262    /// `|b| + (|e·e − d² w²| / w² + |b|²) / |d|`, with `|b|² = |e × m|² /
263    /// (w² m·m)`. `None` where the box is too wide to bound it.
264    fn missed(
265        &self,
266        distance: Scalar,
267        u: (Scalar, Scalar),
268        v: (Scalar, Scalar),
269    ) -> GeopResult<Option<Scalar>> {
270        let w = self.w.restricted(u, v)?;
271        let m = self.m.restricted(u, v)?;
272        let e = self.e.restricted(u, v)?;
273        let along = e.product(&m, dot)?.hull()[0].mul(distance);
274        if !along.definitely_greater(Scalar::ZERO) {
275            return Ok(None);
276        }
277        let w_squared = w.product(&w, product)?;
278        let across = e.product(&m, cross)?;
279        let Some(b_squared) = ratio_bound(
280            &across.product(&across, dot)?,
281            &w_squared.product(&m.product(&m, dot)?, product)?,
282        )?
283        else {
284            return Ok(None);
285        };
286        let Some(length) = ratio_bound(
287            &e.product(&e, dot)?
288                .minus(&w_squared.scaled(distance.mul(distance)))?,
289            &w_squared,
290        )?
291        else {
292            return Ok(None);
293        };
294        let b = b_squared.sqrt()?;
295        let along = length.add(b_squared).div(distance.abs())?;
296        Ok(Some(b.add(along).upper()))
297    }
298}
299
300/// A bound on `|num| / den` over their box, for `den` positive there: the
301/// largest ratio of their Bernstein coefficients at a common degree, as
302/// `Σ B_i a_i ≤ max_i (a_i / d_i) Σ B_i d_i` for positive `d_i`. Unlike the
303/// ratio of their hulls, it does not take the numerator's largest value
304/// over the denominator's least where the two are far apart, as across a
305/// span of weights that fall off. `None` where a coefficient of `den` is
306/// not definitely positive.
307fn ratio_bound(num: &Net<1>, den: &Net<1>) -> GeopResult<Option<Scalar>> {
308    let (p, q) = (
309        num.c.len().max(den.c.len()) - 1,
310        num.c[0].len().max(den.c[0].len()) - 1,
311    );
312    let (num, den) = (num.raised(p, q)?, den.raised(p, q)?);
313    let mut bound = Scalar::ZERO;
314    for (a, d) in num.c.iter().flatten().zip(den.c.iter().flatten()) {
315        if !d[0].definitely_greater(Scalar::ZERO) {
316            return Ok(None);
317        }
318        bound = bound.max(a[0].abs().upper().div(d[0])?.upper());
319    }
320    Ok(Some(bound))
321}
322
323impl Shape {
324    fn new(patch: &NurbSurface3D) -> GeopResult<Self> {
325        let (a, w, m, sides) = parts(patch)?;
326        // `P_a = w A_a − w_a A` and `w A_ab − w_ab A`.
327        let along = |t: bool| -> GeopResult<Net<3>> {
328            w.product(&a.derivative(t)?, scale)?
329                .minus(&w.derivative(t)?.product(&a, scale)?)
330        };
331        let (p_u, p_v) = (along(true)?, along(false)?);
332        let across = |s: bool, t: bool| -> GeopResult<Net<1>> {
333            let a_st = a.derivative(s)?.derivative(t)?;
334            let w_st = w.derivative(s)?.derivative(t)?;
335            w.product(&a_st, scale)?
336                .minus(&w_st.product(&a, scale)?)?
337                .product(&m, dot)
338        };
339        let mut first = [
340            p_u.product(&p_u, dot)?,
341            p_u.product(&p_v, dot)?,
342            p_v.product(&p_v, dot)?,
343        ];
344        let mut second = [
345            across(true, true)?,
346            across(true, false)?,
347            across(false, false)?,
348        ];
349        // Along a pole's side `P_t` vanishes and the tangent across it does
350        // not: `P_t·P_t` has its factor twice, `P_t·P_s` once. `J_tt` has it
351        // twice at a smooth pole, once at an apex; `J_ts` once.
352        let mut twist: Option<Net<1>> = None;
353        for &side in &sides {
354            let t = if side.u_fixed { 2 } else { 0 };
355            let once = divide(&second[t], side, 1)?;
356            let (twice, times) = match once.divided(side)? {
357                Some(twice) => (twice, 2),
358                None => (once, 1),
359            };
360            second[t] = twice;
361            first[t] = divide(&first[t], side, times)?;
362            second[1] = second[1].divided(side)?.ok_or_else(|| {
363                GeopError::new(format!(
364                    "the surface twists at its apex at {side:?}: its offset is not regular there"
365                ))
366            })?;
367            first[1] = divide(&first[1], side, 1)?;
368            if times == 1 {
369                // `det G / t = G'_tt G_ss − t G'_ts²`.
370                let factor = side_factor(side, &first[1]);
371                twist = Some(match twist {
372                    Some(f) => f.product(&factor, product)?,
373                    None => factor,
374                });
375            }
376        }
377        Ok(Shape {
378            w,
379            m,
380            first,
381            second,
382            twist,
383        })
384    }
385
386    /// Whether the offset by `distance` is regular over the box `u` × `v`
387    /// (see the module docs): `Some(true)` everywhere, `Some(false)`
388    /// nowhere, `None` undecided — and how far from zero the entries it
389    /// decides by could be there, the larger of their hulls' magnitudes. `closes` is the side of the span where
390    /// the offset closes to a point, if it does there; `flipped`, whether
391    /// the span lies past an apex the surface was carried on through (see
392    /// `offset`).
393    fn regular(
394        &self,
395        distance: Scalar,
396        u: (Scalar, Scalar),
397        v: (Scalar, Scalar),
398        closes: Option<Side>,
399        flipped: bool,
400    ) -> GeopResult<(Option<bool>, f64)> {
401        let w = self.w.restricted(u, v)?;
402        let m = self.m.restricted(u, v)?;
403        // The entries with `1 / |m|` as a polynomial `g / √r` fitted over
404        // the box (see [`inverse_length`]).
405        let Some((fitted, r)) = inverse_length(&m)? else {
406            return Ok((None, f64::INFINITY));
407        };
408        let factor = distance.div(r.sqrt()?)?;
409        let c = w.product(&w, product)?.product(&fitted, product)?;
410        let g = (0..3)
411            .map(|i| {
412                self.first[i].restricted(u, v)?.minus(
413                    &c.product(&self.second[i].restricted(u, v)?, product)?
414                        .scaled(factor),
415                )
416            })
417            .collect::<GeopResult<Vec<_>>>()?;
418        let twist = self
419            .twist
420            .as_ref()
421            .map(|t| t.restricted(u, v))
422            .transpose()?;
423        let (t, s) = match closes {
424            Some(side) if side.u_fixed => (2, 0),
425            _ => (0, 2),
426        };
427        // Past an apex the offset keeps its way round where the surface
428        // turns over: one factor `1 − d κ` changes sign with it there.
429        let verdict = |diagonal: Scalar, det: Scalar| {
430            let det = if flipped { det.neg() } else { det };
431            let size = diagonal
432                .abs()
433                .upper()
434                .to_f64()
435                .max(det.abs().upper().to_f64());
436            let said = if diagonal.definitely_greater(Scalar::ZERO)
437                && det.definitely_greater(Scalar::ZERO)
438            {
439                Some(true)
440            } else if diagonal.definitely_less(Scalar::ZERO) || det.definitely_less(Scalar::ZERO) {
441                Some(false)
442            } else {
443                None
444            };
445            (said, size)
446        };
447        let squared = g[1].product(&g[1], product)?;
448        let squared = match &twist {
449            Some(t) => squared.product(t, product)?,
450            None => squared,
451        };
452        let det = g[0].product(&g[2], product)?.minus(&squared)?.hull()[0];
453        let said = verdict(g[s].hull()[0], det);
454        let Some(side) = closes.filter(|_| said.0.is_none()) else {
455            return Ok(said);
456        };
457        // The offset's row along `side` is a point: `G e_t = 0` there, `t`
458        // the side's own direction. So by the mean value theorem `G_tt = x
459        // G'_tt` and `G_ts = x G'_ts`, `x` the distance across from the
460        // side (negative from the end), the primes enclosing the
461        // derivatives across over the box carried on to the side, and
462        // `det G / x = G'_tt G_ss − G'_ts G_ts`. Undecided as its entries
463        // are near the side, the box is decided so.
464        let across = side.u_fixed;
465        let (eu, ev) = match (side.u_fixed, side.at_end) {
466            (true, false) => ((self.w.u.0, u.1), v),
467            (true, true) => ((u.0, self.w.u.1), v),
468            (false, false) => (u, (self.w.v.0, v.1)),
469            (false, true) => (u, (v.0, self.w.v.1)),
470        };
471        let w = self.w.restricted(eu, ev)?;
472        let m = self.m.restricted(eu, ev)?;
473        // `G_ab = P_a·P_b − d ρ K_ab`, `K_ab = w² g J_ab`, `ρ = 1 / √r`
474        // and `r = g² (m·m)` near one, so `∂ρ = −ρ³ ∂r / 2` is small.
475        let Some((fitted, _)) = inverse_length(&m)? else {
476            return Ok((None, f64::INFINITY));
477        };
478        let r = fitted
479            .product(&fitted, product)?
480            .product(&m.product(&m, dot)?, product)?;
481        let rho = Scalar::ONE.div(r.hull()[0].sqrt()?)?;
482        let drho = rho
483            .mul(rho)
484            .mul(rho)
485            .mul(r.derivative(across)?.hull()[0])
486            .div(Scalar::TWO)?
487            .neg();
488        let c = w.product(&w, product)?.product(&fitted, product)?;
489        let derivative = |i: usize| -> GeopResult<Scalar> {
490            let k = c.product(&self.second[i].restricted(eu, ev)?, product)?;
491            Ok(self.first[i]
492                .restricted(eu, ev)?
493                .derivative(across)?
494                .minus(&k.derivative(across)?.scaled(distance.mul(rho)))?
495                .minus(&k.scaled(distance.mul(drho)))?
496                .hull()[0])
497        };
498        let x = if side.at_end {
499            Scalar::ONE.neg()
500        } else {
501            Scalar::ONE
502        };
503        let ts = g[1].hull()[0];
504        let ts = match &twist {
505            Some(f) => ts.mul(f.hull()[0]),
506            None => ts,
507        };
508        let det = x.mul(
509            derivative(t)?
510                .mul(g[s].hull()[0])
511                .sub(derivative(1)?.mul(ts)),
512        );
513        Ok(verdict(g[s].hull()[0], det))
514    }
515}
516
517/// What [`over_boxes`]' `decide` says of a box: something to take, or to
518/// split it, telling its quarters what it found there.
519enum Said<T, W> {
520    Is(T),
521    Split(W),
522}
523
524/// The boxes of `surface`'s spans, each split in four until `decide` says
525/// something of it — told, after `splits` splits, that it is the last
526/// chance, and what it found of the box it was split from; what it said of
527/// each, in turn, to `take`. Refused where it says nothing then, or past
528/// [`MAX_OFFSET_BOXES`] boxes, naming the box.
529fn over_boxes<T, S, W: Clone>(
530    surface: &NurbSurface3D,
531    what: &str,
532    splits: usize,
533    mut prepare: impl FnMut(((Scalar, Scalar), (Scalar, Scalar))) -> GeopResult<S>,
534    mut decide: impl FnMut(
535        &S,
536        (Scalar, Scalar),
537        (Scalar, Scalar),
538        bool,
539        Option<&W>,
540    ) -> GeopResult<Said<T, W>>,
541    mut take: impl FnMut(T, (Scalar, Scalar), (Scalar, Scalar)),
542) -> GeopResult<()> {
543    let bu = breakpoints(&surface.knot_vector_u, surface.degree_u, surface.num_u);
544    let bv = breakpoints(&surface.knot_vector_v, surface.degree_v, surface.num_v);
545    let half = Scalar::from_f64(0.5);
546    let mut boxes = 0usize;
547    for su in bu.windows(2) {
548        for sv in bv.windows(2) {
549            let span = ((su[0], su[1]), (sv[0], sv[1]));
550            let piece = prepare(span)
551                .map_err(|e| e.with_context(format!("the span u={:?}, v={:?}", span.0, span.1)))?;
552            let mut stack: Vec<(_, _, usize, Option<W>)> = vec![(span.0, span.1, 0usize, None)];
553            while let Some((u, v, depth, parent)) = stack.pop() {
554                boxes += 1;
555                if boxes > MAX_OFFSET_BOXES {
556                    return Err(GeopError::new(format!(
557                        "{what} was not decided within {MAX_OFFSET_BOXES} boxes; the last one u={u:?}, v={v:?}"
558                    )));
559                }
560                let last = depth >= splits;
561                let found = match decide(&piece, u, v, last, parent.as_ref())
562                    .map_err(|e| e.with_context(format!("{what} over u={u:?}, v={v:?}")))?
563                {
564                    Said::Is(said) => {
565                        take(said, u, v);
566                        continue;
567                    }
568                    Said::Split(found) => found,
569                };
570                if last {
571                    return Err(GeopError::new(format!(
572                        "{what} could not be decided over u={u:?}, v={v:?}, split {splits} times"
573                    )));
574                }
575                let (cu, cv) = (
576                    Scalar::fraction(u.0, u.1, half),
577                    Scalar::fraction(v.0, v.1, half),
578                );
579                for a in [(u.0, cu), (cu, u.1)] {
580                    for b in [(v.0, cv), (cv, v.1)] {
581                        stack.push((a, b, depth + 1, Some(found.clone())));
582                    }
583                }
584            }
585        }
586    }
587    Ok(())
588}
589
590/// A surface offset, and how: `distance` along its normal, the normal
591/// continued through the apex `closing` names, if it has one inside (see
592/// [`Closing`]).
593struct Offsetting<'a> {
594    surface: &'a NurbSurface3D,
595    distance: Scalar,
596    closing: Option<&'a Closing>,
597}
598
599impl Offsetting<'_> {
600    /// Whether the parameter across the closing side, `x`, lies past the
601    /// apex inside, on the closing side's side of it: where the surface
602    /// is its own continuation through the apex, its normal the other way.
603    fn flipped(&self, u: Scalar, v: Scalar) -> bool {
604        let Some(Closing {
605            side,
606            apex: Some(apex),
607        }) = self.closing
608        else {
609            return false;
610        };
611        let x = if side.u_fixed { u } else { v };
612        if side.at_end {
613            x.definitely_greater(*apex)
614        } else {
615            x.definitely_less(*apex)
616        }
617    }
618
619    /// The distance to offset by over the box `u` × `v`, inside one span:
620    /// against the surface's own normal past an apex inside.
621    fn distance_over(&self, u: (Scalar, Scalar), v: (Scalar, Scalar)) -> GeopResult<Scalar> {
622        Ok(if self.flipped_over(u, v)? {
623            self.distance.neg()
624        } else {
625            self.distance
626        })
627    }
628
629    /// Whether the box `u` × `v`, inside one span, lies past an apex inside.
630    fn flipped_over(&self, u: (Scalar, Scalar), v: (Scalar, Scalar)) -> GeopResult<bool> {
631        let mid = |(a, b): (Scalar, Scalar)| a.add(b).div(Scalar::TWO);
632        Ok(self.flipped(mid(u)?, mid(v)?))
633    }
634
635    /// The shift `d N` at `(u, v)`, the normal continued through an apex
636    /// inside: at the apex itself its limit along the parameter line, from
637    /// the side that is not flipped.
638    fn shift(&self, u: Scalar, v: Scalar) -> GeopResult<Vector3> {
639        if let Some(Closing {
640            side,
641            apex: Some(apex),
642        }) = self.closing
643        {
644            let x = if side.u_fixed { u } else { v };
645            if x.could_be_equal(*apex) {
646                // Next to a collapsed `v` row `S_u ≈ (v − v₀) S_uv`, next to
647                // a collapsed `u` row `S_v ≈ (u − u₀) S_uv`.
648                let s = self.surface.partials(u, v, 2)?;
649                let limit = if side.u_fixed {
650                    s[1][0].prod_cross(&s[1][1])
651                } else {
652                    s[1][1].prod_cross(&s[0][1])
653                };
654                // The side not flipped is after the apex for a closing
655                // side at the start.
656                let limit = if side.at_end { limit.neg() } else { limit };
657                return Ok(limit.normalize()?.prod_scalar(self.distance));
658            }
659        }
660        let normal = self.surface.normal(u, v)?;
661        Ok(normal.prod_scalar(if self.flipped(u, v) {
662            self.distance.neg()
663        } else {
664            self.distance
665        }))
666    }
667
668    /// The side the span `u` × `v` touches where the offset closes.
669    fn closes_over(&self, u: (Scalar, Scalar), v: (Scalar, Scalar)) -> Option<Side> {
670        let side = self.closing?.side;
671        let (x, (lo, hi)) = if side.u_fixed {
672            (u, self.surface.domain_u())
673        } else {
674            (v, self.surface.domain_v())
675        };
676        let touches = if side.at_end {
677            x.1.could_be_equal(hi)
678        } else {
679            x.0.could_be_equal(lo)
680        };
681        touches.then_some(side)
682    }
683}
684
685impl NurbSurface3D {
686    /// Whether the surface has a normal everywhere: its normal field, a
687    /// pole's factor divided out, definitely not zero over every span, each
688    /// split in four up to [`REGULAR_SPLITS`] times. `false` where it
689    /// vanishes, or could: a patch carried on so far that it folds back on
690    /// itself, its offset no surface.
691    pub fn has_normal_everywhere(&self) -> GeopResult<bool> {
692        let mut everywhere = true;
693        over_boxes(
694            self,
695            "whether the surface has a normal",
696            REGULAR_SPLITS,
697            |(u, v)| Ok(parts(&self.sub_surface(u, v)?)?.2),
698            |m, u, v, last, _: Option<&()>| {
699                let m = m.restricted(u, v)?;
700                let squared = m.product(&m, dot)?.hull()[0];
701                Ok(if squared.definitely_greater(Scalar::ZERO) {
702                    Said::Is(true)
703                } else if last {
704                    Said::Is(false)
705                } else {
706                    Said::Split(())
707                })
708            },
709            |has, _, _| everywhere &= has,
710        )?;
711        Ok(everywhere)
712    }
713
714    /// The surface `distance` along its normal, fitted and widened to
715    /// enclose it (see the module docs), with the same parametrization;
716    /// `None` where it collapses (see [`NurbSurface3D::offset`]). Fails
717    /// where the surface has no normal, or the offset folds. `closing`
718    /// says where the surface was moved so that its offset closes at an
719    /// apex.
720    pub(super) fn fitted_offset(
721        &self,
722        distance: Scalar,
723        closing: Option<&Closing>,
724    ) -> GeopResult<Option<Self>> {
725        let ctx = |e: GeopError| e.with_context(format!("fitted_offset(distance={distance:?})"));
726        let offsetting = Offsetting {
727            surface: self,
728            distance,
729            closing,
730        };
731        // Regular, collapsed or folded is the surface's and the distance's
732        // to say, over its own spans, before anything is fitted.
733        let (mut regular, mut collapsed, mut undecided) = (None, None, None);
734        over_boxes(
735            self,
736            "whether the offset is regular",
737            REGULAR_SPLITS,
738            |(u, v)| {
739                Ok((
740                    Shape::new(&self.sub_surface(u, v)?)?,
741                    offsetting.distance_over(u, v)?,
742                    offsetting.flipped_over(u, v)?,
743                    offsetting.closes_over(u, v),
744                ))
745            },
746            |(shape, distance, flipped, closes), u, v, last, parent: Option<&(f64, usize)>| {
747                let (said, size) = shape.regular(*distance, u, v, *closes, *flipped)?;
748                // How many splits in a row have not halved how far from zero
749                // the box's entries could be.
750                let stalled = match parent {
751                    Some(&(p, n)) if p <= 2.0 * size => n + 1,
752                    _ => 0,
753                };
754                Ok(match said {
755                    Some(said) => Said::Is(Some(said)),
756                    // Undecided at the last split, or where splitting the
757                    // box has stopped narrowing how far from zero its
758                    // entries could be, [`STALLED_SPLITS`] times in a row: a
759                    // factor `1 − d κ` as near zero as the arithmetic
760                    // resolves, as a cylinder's offset by its radius has
761                    // all over, which no split decides.
762                    None if last || stalled >= STALLED_SPLITS => Said::Is(None),
763                    None => Said::Split((size, stalled)),
764                })
765            },
766            |said, u, v| match said {
767                Some(true) => regular = Some((u, v)),
768                Some(false) => collapsed = Some((u, v)),
769                None => undecided = Some((u, v)),
770            },
771        )
772        .map_err(ctx)?;
773        match (regular, collapsed, undecided) {
774            (Some(at), Some(not), _) => {
775                return Err(ctx(GeopError::new(format!(
776                    "the offset by {distance:?} folds over itself: it is regular over u={:?}, v={:?}, but passes a centre of curvature over u={:?}, v={:?}",
777                    at.0, at.1, not.0, not.1
778                ))));
779            }
780            (Some(at), None, Some((u, v))) => {
781                return Err(ctx(GeopError::new(format!(
782                    "the offset by {distance:?} could fold over itself over u={u:?}, v={v:?}: a factor 1 − d κ could be zero there, and it is regular over u={:?}, v={:?}",
783                    at.0, at.1
784                ))));
785            }
786            // Nowhere regular: collapsed, or as near a curve as can be told.
787            (None, ..) => return Ok(None),
788            _ => {}
789        }
790        // How finely to fit is a matter of effort, so it is chosen from a
791        // look at the fit between its points; whether the fit encloses the
792        // offset is the bound's to say, over every box, after. The
793        // surface's own net first: where it can follow the offset, the
794        // offset keeps its degrees and its layout.
795        let own = self.fitted_shift(&offsetting).map_err(ctx)?;
796        let (net, shift) = if own.fits(&offsetting).map_err(ctx)? {
797            (self.clone(), own)
798        } else {
799            let mut base = self.clone();
800            while base.degree_u < 3 {
801                base = base.elevate_degree(true).map_err(ctx)?;
802            }
803            while base.degree_v < 3 {
804                base = base.elevate_degree(false).map_err(ctx)?;
805            }
806            let mut pieces = 1;
807            loop {
808                let net = base.refined([pieces, pieces]).map_err(ctx)?;
809                let shift = net.fitted_shift(&offsetting).map_err(ctx)?;
810                if pieces < MAX_OFFSET_PIECES && !shift.fits(&offsetting).map_err(ctx)? {
811                    pieces *= 2;
812                    continue;
813                }
814                break (net, shift);
815            }
816        };
817        let drift = Scalar::from_f64(OFFSET_DRIFT);
818        let mut bounds = Vec::new();
819        over_boxes(
820            &shift,
821            "the bound on what the fit misses",
822            BOUND_SPLITS,
823            |(u, v)| {
824                Ok((
825                    Fit::new(&self.sub_surface(u, v)?, &shift.sub_surface(u, v)?)?,
826                    offsetting.distance_over(u, v)?,
827                ))
828            },
829            // Split while the bound is wider than the drift, and take it
830            // as it is once splitting no longer pays: it is honest either
831            // way, only wider.
832            |(fit, distance), u, v, last, _: Option<&()>| {
833                Ok(
834                    match fit
835                        .missed(*distance, u, v)?
836                        .filter(|p| last || !p.definitely_greater(drift))
837                    {
838                        Some(missed) => Said::Is(missed),
839                        None => Said::Split(()),
840                    },
841                )
842            },
843            |missed, u, v| bounds.push((u, v, missed)),
844        )
845        .map_err(|e| ctx(e).with_context(format!("the shift fitted on {net:?}")))?;
846        let offset = net.shifted(&shift, &bounds);
847        if let Some(closing) = closing
848            && !poles(&offset)?.contains(&closing.side)
849        {
850            return Err(ctx(GeopError::new(format!(
851                "the offset of the apex does not close in a point along its side {:?}: the offsets of the surface round it do not meet in one point",
852                closing.side
853            ))));
854        }
855        Ok(Some(offset))
856    }
857
858    /// Whether this shift is within half [`OFFSET_DRIFT`] of the offset
859    /// halfway between its Greville points, in the middle of each span: a
860    /// look, to choose how finely to fit, not a bound.
861    fn fits(&self, offsetting: &Offsetting) -> GeopResult<bool> {
862        let drift = Scalar::from_f64(OFFSET_DRIFT / 2.);
863        let middles = |knots: &[Scalar], degree: usize, n: usize| -> GeopResult<Vec<Scalar>> {
864            let b = breakpoints(knots, degree, n);
865            b.windows(2)
866                .flat_map(|w| [1, 2, 3].map(|k| (w[0], w[1], k)))
867                .map(|(a, b, k)| Ok(Scalar::fraction(a, b, Scalar::from_ratio(k, 4)?)))
868                .collect()
869        };
870        for u in middles(&self.knot_vector_u, self.degree_u, self.num_u)? {
871            for v in middles(&self.knot_vector_v, self.degree_v, self.num_v)? {
872                let e = self.evaluate(u, v)?.sub(&offsetting.shift(u, v)?);
873                if (0..3).any(|k| e[k].abs().definitely_greater(drift)) {
874                    return Ok(false);
875                }
876            }
877        }
878        Ok(true)
879    }
880
881    /// The shift that moves this surface, as a net to fit on, onto the
882    /// offset: in this net's rational basis, its homogeneous control points
883    /// interpolating `W d N` at the Greville points, `W` the net's weight
884    /// there. Its control points are sharp: a fit is a free choice, what it
885    /// misses is bounded afterwards.
886    fn fitted_shift(&self, offsetting: &Offsetting) -> GeopResult<Self> {
887        let greville = |knots: &[Scalar], degree: usize, n: usize| -> GeopResult<Vec<Scalar>> {
888            (0..n)
889                .map(|i| {
890                    let sum = knots[i + 1..=i + degree]
891                        .iter()
892                        .fold(Scalar::ZERO, |a, &k| a.add(k));
893                    // Inside the domain: a division by the degree rounds
894                    // out of it at an end.
895                    let mean = sum.div(Scalar::from_i64(degree as i64))?.sharpen();
896                    Ok(mean.max(knots[degree]).min(knots[n]))
897                })
898                .collect()
899        };
900        let (nu, nv) = (self.num_u, self.num_v);
901        let gu = greville(&self.knot_vector_u, self.degree_u, nu)?;
902        let gv = greville(&self.knot_vector_v, self.degree_v, nv)?;
903        // Along `v` for each `u` of the grid, then along `u`.
904        let mut rows = Vec::with_capacity(nu);
905        for &u in &gu {
906            let values = gv
907                .iter()
908                .map(|&v| {
909                    let shift = offsetting.shift(u, v).map_err(|e| {
910                        e.with_context(format!("the shift at the Greville point {:?}", (u, v)))
911                    })?;
912                    Ok(shift.prod_scalar(self.weight(u, v)?))
913                })
914                .collect::<GeopResult<Vec<Vector3>>>()?;
915            rows.push(solve_interpolation_system(
916                &gv,
917                &self.knot_vector_v,
918                self.degree_v,
919                &values,
920            )?);
921        }
922        let mut shifts = vec![Vector3::zero(); nu * nv];
923        for j in 0..nv {
924            let column: Vec<Vector3> = rows.iter().map(|row| row[j]).collect();
925            let solved =
926                solve_interpolation_system(&gu, &self.knot_vector_u, self.degree_u, &column)?;
927            for (i, s) in solved.into_iter().enumerate() {
928                shifts[i * nv + j] = s;
929            }
930        }
931        let control_points = shifts
932            .iter()
933            .zip(&self.control_points)
934            .map(|(s, cp)| {
935                Vector::from_array([s[0].sharpen(), s[1].sharpen(), s[2].sharpen(), cp[3]])
936            })
937            .collect();
938        NurbSurface3D::try_new(
939            self.degree_u,
940            self.degree_v,
941            control_points,
942            self.knot_vector_u.clone(),
943            self.knot_vector_v.clone(),
944        )
945    }
946
947    /// This net moved by `shift` (homogeneous control points added, the
948    /// weights kept) and widened in each coordinate by the largest of the
949    /// `bounds` over the boxes its basis function is not zero on: wherever
950    /// the surface is evaluated, every control point it is made of is then
951    /// widened by at least the bound there, and the basis sums to one. A
952    /// span carried on far, its weights small, widens only the points it
953    /// is made of.
954    #[allow(clippy::type_complexity)]
955    fn shifted(
956        &self,
957        shift: &NurbSurface3D,
958        bounds: &[((Scalar, Scalar), (Scalar, Scalar), Scalar)],
959    ) -> Self {
960        let support = |knots: &[Scalar], degree: usize, i: usize, (a, b): (Scalar, Scalar)| {
961            knots[i].definitely_less(b) && a.definitely_less(knots[i + degree + 1])
962        };
963        let control_points = self
964            .control_points
965            .iter()
966            .zip(&shift.control_points)
967            .enumerate()
968            .map(|(index, (cp, s))| {
969                let (i, j) = (index / self.num_v, index % self.num_v);
970                let pad = bounds
971                    .iter()
972                    .filter(|(u, v, _)| {
973                        support(&self.knot_vector_u, self.degree_u, i, *u)
974                            && support(&self.knot_vector_v, self.degree_v, j, *v)
975                    })
976                    .fold(Scalar::ZERO, |pad, (_, _, missed)| pad.max(*missed));
977                let w = cp[3];
978                let mut out = *cp;
979                let d = pad.mul(w);
980                for k in 0..3 {
981                    let moved = cp[k].add(s[k]);
982                    out[k] = moved.sub(d).union(moved.add(d));
983                }
984                out
985            })
986            .collect();
987        let mut out = self.clone();
988        out.control_points = control_points;
989        out.recompute_aabb();
990        out
991    }
992}
993
994#[cfg(test)]
995mod tests {
996    use geop_core_math::{scalars::Scalar, vector::Vector4};
997
998    use super::*;
999    use crate::test_fixtures::{hom, knots};
1000
1001    /// A bicubic bump over the unit square: corners down, middle up by
1002    /// `height`.
1003    fn bump(height: f64) -> NurbSurface3D {
1004        let z = |i: usize, j: usize| {
1005            let edge = |k: usize| k == 0 || k == 3;
1006            if edge(i) || edge(j) { 0. } else { height }
1007        };
1008        let control_points: Vec<Vector4> = (0..4)
1009            .flat_map(|i| (0..4).map(move |j| (i, j)))
1010            .map(|(i, j)| hom(i as f64 / 3., j as f64 / 3., z(i, j), 1.))
1011            .collect();
1012        let k = knots(&[0., 0., 0., 0., 1., 1., 1., 1.]);
1013        NurbSurface3D::try_new(3, 3, control_points, k.clone(), k).unwrap()
1014    }
1015
1016    /// Whether `offset` encloses `surface` moved `distance` along its
1017    /// normal at a grid of `(u, v)` that is not the fit's, and is at most
1018    /// `width` wide.
1019    fn assert_encloses(surface: &NurbSurface3D, offset: &NurbSurface3D, distance: f64, width: f64) {
1020        for a in 0..=7 {
1021            for b in 0..=7 {
1022                let (u, v) = (
1023                    Scalar::from_ratio(a, 7).unwrap(),
1024                    Scalar::from_ratio(b, 7).unwrap(),
1025                );
1026                let expected = surface.evaluate(u, v).unwrap().add(
1027                    &surface
1028                        .normal(u, v)
1029                        .unwrap()
1030                        .prod_scalar(Scalar::from_f64(distance)),
1031                );
1032                let got = offset.evaluate(u, v).unwrap();
1033                assert!(
1034                    got.could_be_equal(&expected),
1035                    "at ({a}, {b}): {got:?}, expected {expected:?}"
1036                );
1037            }
1038        }
1039        for cp in &offset.control_points {
1040            for k in 0..3 {
1041                assert!(cp[k].width().to_f64() < width, "{cp:?}");
1042            }
1043        }
1044    }
1045
1046    /// The fit alone, before it is bounded: its error off the Greville
1047    /// points falls as the spans are split finer: on a bump this steep,
1048    /// four to six times for each halving, still short of the sixteen of a
1049    /// cubic's asymptote.
1050    #[test]
1051    fn the_fit_converges() {
1052        let surface = bump(0.5);
1053        let distance = Scalar::from_f64(0.1);
1054        let error = |pieces: usize| {
1055            let net = surface.refined([pieces, pieces]).unwrap();
1056            let offsetting = Offsetting {
1057                surface: &surface,
1058                distance,
1059                closing: None,
1060            };
1061            let shift = net.fitted_shift(&offsetting).unwrap();
1062            let mut worst: f64 = 0.;
1063            for a in 0..=13 {
1064                for b in 0..=13 {
1065                    let (u, v) = (
1066                        Scalar::from_ratio(a, 13).unwrap(),
1067                        Scalar::from_ratio(b, 13).unwrap(),
1068                    );
1069                    let e = shift
1070                        .evaluate(u, v)
1071                        .unwrap()
1072                        .sub(&surface.normal(u, v).unwrap().prod_scalar(distance));
1073                    worst = (0..3).map(|c| e[c].abs().to_f64()).fold(worst, f64::max);
1074                }
1075            }
1076            worst
1077        };
1078        let errors: Vec<f64> = [1, 2, 4, 8].map(error).to_vec();
1079        assert!(errors.windows(2).all(|w| w[1] < w[0] / 3.), "{errors:?}");
1080    }
1081
1082    /// A gentle bump, the fast case.
1083    #[test]
1084    fn a_free_form_bump_is_offset_both_ways() {
1085        let surface = bump(0.1);
1086        for d in [0.05, -0.05] {
1087            let offset = surface.offset(Scalar::from_f64(d)).unwrap().unwrap();
1088            assert_encloses(&surface, &offset, d, 5e-5);
1089        }
1090    }
1091
1092    /// A steep bump: its normal turns fast, so its fit takes more spans
1093    /// and its bound more boxes, seconds of them.
1094    #[test]
1095    #[ignore = "slow: 7 s — run with `cargo test -- --ignored`; `a_free_form_bump_is_offset_both_ways` is the fast case"]
1096    fn a_steep_free_form_bump_is_offset_both_ways() {
1097        let surface = bump(0.5);
1098        for d in [0.1, -0.1] {
1099            let offset = surface.offset(Scalar::from_f64(d)).unwrap().unwrap();
1100            assert_encloses(&surface, &offset, d, 5e-5);
1101        }
1102    }
1103
1104    /// A rational surface fitted, not taken exactly: a torus patch, whose
1105    /// offset the fit has to enclose like any other.
1106    #[test]
1107    fn a_rational_patch_is_fitted() {
1108        let r2 = std::f64::consts::FRAC_1_SQRT_2;
1109        let ring = [(2., 0., 1.), (2., 2., r2), (0., 2., 1.)];
1110        let tube = [(1., 0., 1.), (1., 1., r2), (0., 1., 1.)];
1111        let control_points: Vec<Vector4> = ring
1112            .iter()
1113            .flat_map(|&(x, y, wr)| {
1114                tube.iter().map(move |&(r, z, wt)| {
1115                    let (x, y) = (x * (1. + r) / 2., y * (1. + r) / 2.);
1116                    let w = wr * wt;
1117                    hom(x * w, y * w, z * w, w)
1118                })
1119            })
1120            .collect();
1121        let k = knots(&[0., 0., 0., 1., 1., 1.]);
1122        let torus = NurbSurface3D::try_new(2, 2, control_points, k.clone(), k).unwrap();
1123        for d in [0.25, -0.25] {
1124            let fitted = torus
1125                .fitted_offset(Scalar::from_f64(d), None)
1126                .unwrap()
1127                .unwrap();
1128            assert_encloses(&torus, &fitted, d, 5e-5);
1129        }
1130    }
1131
1132    /// A patch with a pole: a sphere's octant, its rows meeting at the top.
1133    /// Its normal there is no `S_u × S_v`, which vanishes; the normal
1134    /// field with the pole's factor divided out is (see `net`). Fitted,
1135    /// not taken as a revolution.
1136    #[test]
1137    fn a_patch_with_a_pole_is_fitted() {
1138        let sphere = crate::test_fixtures::sphere_octant();
1139        for d in [0.2, -0.2] {
1140            let fitted = sphere
1141                .fitted_offset(Scalar::from_f64(d), None)
1142                .unwrap()
1143                .unwrap();
1144            assert_encloses(&sphere, &fitted, d, 5e-5);
1145        }
1146    }
1147
1148    /// A sphere's octant offset inward past its centre: both of its
1149    /// curvatures pass, so `O_u × O_v` keeps its side, and only `I − d II`
1150    /// being negative definite tells it collapses.
1151    #[test]
1152    fn a_sphere_offset_past_its_centre_collapses() {
1153        let sphere = crate::test_fixtures::sphere_octant();
1154        assert!(
1155            sphere
1156                .fitted_offset(Scalar::from_f64(-1.5), None)
1157                .unwrap()
1158                .is_none()
1159        );
1160    }
1161
1162    /// A bump bent tighter than the distance it is offset into: the offset
1163    /// passes a centre of curvature and folds over. Refused.
1164    #[test]
1165    fn an_offset_past_a_centre_of_curvature_is_refused() {
1166        let error = bump(2.).offset(Scalar::from_f64(-0.5)).unwrap_err();
1167        assert!(error.root_message().contains("fold"), "{error:?}");
1168    }
1169}