Skip to main content

axiolid_evaluate/
bound.rs

1//! Certified derivative and chord bounds (#232).
2//!
3//! A chord of a curve, or a triangle of a surface, is only as close to the
4//! exact geometry as the geometry's second derivative allows, and a sample
5//! at the midpoint does not show that: a midpoint can sit on the chord while
6//! the curve bulges either side of it. This module bounds the derivatives
7//! from the data itself, so the numbers it returns are upper bounds, never
8//! estimates.
9//!
10//! # The chord bound
11//!
12//! For a curve `c` that is `C^1` with a bounded second derivative on
13//! `[a, b]`, the linear interpolant `l` of its ends satisfies
14//! `|c(t) - l(t)| <= (b - a)^2 / 8 * sup |c''|`: componentwise,
15//! `c - l = -int G(t, s) c''(s) ds` with the non-negative Green's function
16//! of `-d^2/dt^2` on `[a, b]`, whose integral is `(t - a)(b - t) / 2 <=
17//! (b - a)^2 / 8`. `l(t)` lies on the chord, so every point of the arc is
18//! within that of the chord. A circular arc of at most half a turn uses its
19//! exact sagitta instead.
20//!
21//! # Where the derivative bounds come from
22//!
23//! - Conics: `c'' = -(r_x cos t X + r_y sin t Y)`, bounded in closed form
24//!   over the interval (the frame is used as given, so a skewed frame is
25//!   accounted for, not assumed away).
26//! - B-splines: the derivative of a B-spline is a B-spline on the derivative
27//!   control polygon, and a B-spline lies in the convex hull of the control
28//!   points that act on its span, so each derivative is bounded by the
29//!   largest of those control points. A rational spline `c = A / w` is
30//!   bounded through the quotient rule, with `w` bounded below by its
31//!   smallest active weight (weights are positive) and `c` bounded by the
32//!   control points' spread about their centre; the centre is subtracted
33//!   first, which leaves every derivative unchanged.
34//! - Elementary surfaces: closed forms over the parameter box. B-spline
35//!   surfaces: the derivative control nets of the tensor product.
36//! - Implicit curves (ADR 0077): cell by cell, the solved parameter's
37//!   slope and bend through the implicit function theorem, with interval
38//!   bounds of the field's partials over the stretch's box; a bridge cell
39//!   by its cubic's control points (`implicit`, #249).
40//! - Intrinsic curves (#317): at unit speed `|c''| = |k|`, so the
41//!   curvature law's closed-form supremum, times the start frame's
42//!   stretch, bounds the chords ([`intrinsic_chord_bound2`]); their
43//!   points are read with a certified error by [`IntrinsicPositions`].
44//! - Constant offsets `B + d u` (#317): `|P''|` from the basis's suprema
45//!   through the unit-vector rule of the certified station lengths
46//!   ([`offset_chord_bound2`]).
47//!
48//! A B-spline is only as smooth as its knots allow. A knot of multiplicity
49//! `m` leaves a degree `d` spline `C^(d - m)` there, so a bound that needs a
50//! continuous `k`-th derivative refuses an interval with such a knot
51//! strictly inside: the derivative bound would hold on each side but the
52//! Taylor argument does not cross the jump. [`continuity_breaks2`] and
53//! [`continuity_breaks3`] name those knots so a caller can split there.
54//!
55//! Everything is computed in floating point from the data; the bounds are
56//! inflated by [`ROUNDING`] relative so rounding in their own arithmetic
57//! cannot make them undershoot.
58
59use axiolid_core::{Frame2, Frame3, Scalar, Vec2, Vec3};
60use axiolid_curve::{BSplineCurve, Curve2, Curve3};
61use axiolid_surface::{BSplineSurface, Surface};
62
63use crate::curve::span_in;
64use crate::nurbs::SplineAxis;
65
66mod implicit;
67mod intrinsic;
68mod offset;
69mod positions;
70
71pub use intrinsic::{intrinsic_chord_bound2, intrinsic_chord_bound3, intrinsic_derivative_bounds2};
72pub use offset::{offset_chord_bound2, offset_chord_bound3, offset_point2, offset_point3};
73pub use positions::{IntrinsicPositions, MAX_POSITION_PANELS};
74
75/// Relative inflation applied to every returned bound, so the rounding of
76/// the bound's own arithmetic cannot make it undershoot.
77pub const ROUNDING: Scalar = 1e-12;
78
79fn inflate(value: Scalar) -> Scalar {
80    value * (1.0 + ROUNDING)
81}
82
83/// Largest operator norm of a frame's linear part, bounded through its
84/// Gram matrix: `sigma^2 = lambda_max(F^T F) <= 1 + |F^T F - I|_F`. Exact
85/// for an orthonormal frame, and never below the true stretch for a skewed
86/// or scaled one.
87fn gram_stretch(columns: &[Vec3]) -> Scalar {
88    let mut off: Scalar = 0.0;
89    for (i, a) in columns.iter().enumerate() {
90        for (j, b) in columns.iter().enumerate() {
91            let target = if i == j { 1.0 } else { 0.0 };
92            let entry = a.dot(*b) - target;
93            off += entry * entry;
94        }
95    }
96    (1.0 + off.sqrt()).sqrt()
97}
98
99/// The largest factor by which a 3D frame stretches a local vector.
100#[must_use]
101pub fn frame_stretch3(frame: &Frame3) -> Scalar {
102    inflate(gram_stretch(&[frame.x, frame.y, frame.z]))
103}
104
105/// The largest factor by which a 2D frame stretches a local vector.
106#[must_use]
107pub fn frame_stretch2(frame: &Frame2) -> Scalar {
108    let lift = |v: Vec2| Vec3::new(v.x, v.y, 0.0);
109    inflate(gram_stretch(&[lift(frame.x), lift(frame.y)]))
110}
111
112// --- interval helpers -------------------------------------------------------
113
114/// Whether `[lo, hi]` contains `offset + k * period` for some integer `k`.
115fn contains_lattice(lo: Scalar, hi: Scalar, offset: Scalar, period: Scalar) -> bool {
116    let k = ((lo - offset) / period).ceil();
117    offset + k * period <= hi
118}
119
120/// `max cos^2` over `[lo, hi]`.
121fn max_cos2(lo: Scalar, hi: Scalar) -> Scalar {
122    if contains_lattice(lo, hi, 0.0, core::f64::consts::PI) {
123        1.0
124    } else {
125        lo.cos().powi(2).max(hi.cos().powi(2))
126    }
127}
128
129/// `min cos^2` over `[lo, hi]`.
130fn min_cos2(lo: Scalar, hi: Scalar) -> Scalar {
131    if contains_lattice(lo, hi, core::f64::consts::FRAC_PI_2, core::f64::consts::PI) {
132        0.0
133    } else {
134        lo.cos().powi(2).min(hi.cos().powi(2))
135    }
136}
137
138/// `max |cos|` over `[lo, hi]`.
139fn max_abs_cos(lo: Scalar, hi: Scalar) -> Scalar {
140    max_cos2(lo, hi).sqrt()
141}
142
143/// `max |sin|` over `[lo, hi]`.
144fn max_abs_sin(lo: Scalar, hi: Scalar) -> Scalar {
145    (1.0 - min_cos2(lo, hi)).max(0.0).sqrt()
146}
147
148/// `(min cos, max cos)` over `[lo, hi]`.
149fn cos_range(lo: Scalar, hi: Scalar) -> (Scalar, Scalar) {
150    let tau = core::f64::consts::TAU;
151    let max = if contains_lattice(lo, hi, 0.0, tau) {
152        1.0
153    } else {
154        lo.cos().max(hi.cos())
155    };
156    let min = if contains_lattice(lo, hi, core::f64::consts::PI, tau) {
157        -1.0
158    } else {
159        lo.cos().min(hi.cos())
160    };
161    (min, max)
162}
163
164fn ordered(a: Scalar, b: Scalar) -> (Scalar, Scalar) {
165    if a <= b {
166        (a, b)
167    } else {
168        (b, a)
169    }
170}
171
172/// `sup |c''|` of the conic `o + r_x cos t X + r_y sin t Y` over `[a, b]`.
173///
174/// `|c''|^2 = r_x^2 |X|^2 cos^2 + r_y^2 |Y|^2 sin^2 + 2 r_x r_y X.Y cos sin`;
175/// the first two terms are `beta^2 + (alpha^2 - beta^2) cos^2`, extreme
176/// where `cos^2` is, and the cross term is at most `r_x r_y |X.Y|`.
177fn conic_second(rx: Scalar, ry: Scalar, x: Vec3, y: Vec3, a: Scalar, b: Scalar) -> Scalar {
178    let (lo, hi) = ordered(a, b);
179    let alpha2 = (rx * rx) * x.length_squared();
180    let beta2 = (ry * ry) * y.length_squared();
181    let cos2 = if alpha2 >= beta2 {
182        max_cos2(lo, hi)
183    } else {
184        min_cos2(lo, hi)
185    };
186    let main = beta2 + (alpha2 - beta2) * cos2;
187    let cross = (rx * ry * x.dot(y)).abs();
188    (main.max(0.0) + cross).sqrt()
189}
190
191/// The exact sagitta bound of a circular arc of at most half a turn, when
192/// the frame is orthonormal; `None` otherwise.
193fn circle_sagitta(radius: Scalar, x: Vec3, y: Vec3, a: Scalar, b: Scalar) -> Option<Scalar> {
194    let h = (b - a).abs();
195    let orthonormal = (x.length_squared() - 1.0).abs() <= 1e-12
196        && (y.length_squared() - 1.0).abs() <= 1e-12
197        && x.dot(y).abs() <= 1e-12;
198    (orthonormal && h <= core::f64::consts::PI).then(|| radius.abs() * (1.0 - (0.5 * h).cos()))
199}
200
201fn lift2(v: Vec2) -> Vec3 {
202    Vec3::new(v.x, v.y, 0.0)
203}
204
205// --- B-spline derivative bounds ---------------------------------------------
206
207/// Suprema of the first three derivatives of a curve over an interval.
208///
209/// Each is the supremum over the open pieces between the curve's own
210/// breaks: at a knot where a derivative jumps, both one-sided values are
211/// covered. Whether a Taylor argument may cross such a knot is the
212/// caller's question; see [`continuity_breaks3`].
213#[derive(Debug, Clone, Copy, PartialEq)]
214pub struct CurveDerivativeBounds {
215    /// `sup |c'|`.
216    pub first: Scalar,
217    /// `sup |c''|`.
218    pub second: Scalar,
219    /// `sup |c'''|`.
220    pub third: Scalar,
221}
222
223/// Derivative control polygon of one homogeneous axis: `d (P_{i+1} - P_i) /
224/// (u_{i+d+1} - u_{i+1})`, the zero-width spans contributing nothing.
225fn hodograph<const N: usize>(
226    points: &[[Scalar; N]],
227    weights: &[Scalar],
228    knots: &[Scalar],
229    degree: usize,
230) -> (Vec<[Scalar; N]>, Vec<Scalar>) {
231    let mut out_points = Vec::with_capacity(points.len().saturating_sub(1));
232    let mut out_weights = Vec::with_capacity(weights.len().saturating_sub(1));
233    for i in 0..points.len().saturating_sub(1) {
234        let width = knots[i + degree + 1] - knots[i + 1];
235        let factor = if width > 0.0 {
236            degree as Scalar / width
237        } else {
238            0.0
239        };
240        out_points.push(core::array::from_fn(|k| {
241            (points[i + 1][k] - points[i][k]) * factor
242        }));
243        out_weights.push((weights[i + 1] - weights[i]) * factor);
244    }
245    (out_points, out_weights)
246}
247
248fn norm<const N: usize>(p: &[Scalar; N]) -> Scalar {
249    p.iter().map(|c| c * c).sum::<Scalar>().sqrt()
250}
251
252/// Rational derivative bounds from homogeneous ones: `A = w c`, so
253/// `c' = (A' - w' c) / w`, `c'' = (A'' - 2 w' c' - w'' c) / w`,
254/// `c''' = (A''' - 3 w'' c' - 3 w' c'' - w''' c) / w` and `c'''' = (A'''' -
255/// 4 w''' c' - 6 w'' c'' - 4 w' c''' - w'''' c) / w`.
256fn quotient_bounds(a: [Scalar; 5], w: [Scalar; 5], radius: Scalar, w_min: Scalar) -> [Scalar; 4] {
257    let first = (a[1] + w[1] * radius) / w_min;
258    let second = (a[2] + 2.0 * w[1] * first + w[2] * radius) / w_min;
259    let third = (a[3] + 3.0 * w[2] * first + 3.0 * w[1] * second + w[3] * radius) / w_min;
260    let fourth =
261        (a[4] + 4.0 * w[3] * first + 6.0 * w[2] * second + 4.0 * w[1] * third + w[4] * radius)
262            / w_min;
263    [first, second, third, fourth]
264}
265
266/// `sup |c^(k)|`, `k = 1, 2, 3`, of a B-spline over `[a, b]`, its chord
267/// coefficient `K` (every point of the arc is within `(b - a)^2 / 8 K` of
268/// the chord) and `sup |c''''|` (#307). `None` for invalid data or a
269/// non-positive weight.
270///
271/// The chord coefficient is not `sup |c''|` for a rational spline. With
272/// the centred homogeneous numerator `A = w c` and the linear
273/// interpolants `l_A`, `l_w` of `A` and `w` over `[a, b]`, the point
274/// `q = l_A / l_w` lies on the chord (a projective map keeps segments), and
275/// `c - q = ((A - l_A) - q (w - l_w)) / w`. Both differences are
276/// polynomial chord errors, so `|c - q| <= (b - a)^2 / 8 (|A''| + R
277/// |w''|) / w_min` with `R` bounding `|q|` (the active points' spread):
278/// no first-derivative terms, which the quotient rule for `c''` carries.
279fn bspline_bounds<P, const N: usize>(
280    curve: &BSplineCurve<P>,
281    to: impl Fn(&P) -> [Scalar; N],
282    a: Scalar,
283    b: Scalar,
284) -> Option<(CurveDerivativeBounds, Scalar, Scalar)> {
285    let count = curve.control_points.len();
286    let axis = SplineAxis::new(
287        &curve.knots,
288        &curve.multiplicities,
289        curve.degree,
290        count,
291        "B-spline curve",
292    )
293    .ok()?;
294    let weights: Vec<Scalar> = match &curve.weights {
295        Some(w) if w.len() == count => w.clone(),
296        Some(_) => return None,
297        None => vec![1.0; count],
298    };
299    if weights.iter().any(|w| !(w.is_finite() && *w > 0.0)) {
300        return None;
301    }
302    let degree = axis.degree;
303    let (lo, hi) = ordered(axis.clamp(a), axis.clamp(b));
304    let first_span = span_in(&axis.knots, count, degree, lo);
305    let last_span = span_in(&axis.knots, count, degree, hi);
306    // Control points acting on the spans [first_span, last_span]; every
307    // hodograph's active points fall in the same index window.
308    let window = first_span - degree..=last_span;
309    let points: Vec<[Scalar; N]> = curve.control_points.iter().map(&to).collect();
310    if points.iter().flatten().any(|c| !c.is_finite()) {
311        return None;
312    }
313    // Centre the active points so `|c|` (which the rational quotient rule
314    // multiplies by weight derivatives) is their spread, not their offset.
315    let mut low = [Scalar::INFINITY; N];
316    let mut high = [Scalar::NEG_INFINITY; N];
317    for p in &points[window.clone()] {
318        for k in 0..N {
319            low[k] = low[k].min(p[k]);
320            high[k] = high[k].max(p[k]);
321        }
322    }
323    let centre: [Scalar; N] = core::array::from_fn(|k| 0.5 * (low[k] + high[k]));
324    let radius = points[window.clone()]
325        .iter()
326        .map(|p| norm(&core::array::from_fn::<Scalar, N, _>(|k| p[k] - centre[k])))
327        .fold(0.0, Scalar::max);
328    let w_min = weights[window.clone()]
329        .iter()
330        .copied()
331        .fold(Scalar::INFINITY, Scalar::min);
332    let mut hom: Vec<[Scalar; N]> = points
333        .iter()
334        .zip(&weights)
335        .map(|(p, w)| core::array::from_fn(|k| (p[k] - centre[k]) * w))
336        .collect();
337    let mut hw = weights.clone();
338    let mut knots: &[Scalar] = &axis.knots;
339    let mut a_sup = [0.0; 5];
340    let mut w_sup = [0.0; 5];
341    for order in 1..=4 {
342        let current_degree = degree + 1 - order;
343        if current_degree == 0 || hom.len() < 2 {
344            break;
345        }
346        let (next, next_w) = hodograph(&hom, &hw, knots, current_degree);
347        knots = &knots[1..knots.len() - 1];
348        let end = (*window.end()).min(next.len().saturating_sub(1));
349        let start = (*window.start()).min(end);
350        a_sup[order] = next[start..=end].iter().map(norm).fold(0.0, Scalar::max);
351        w_sup[order] = next_w[start..=end]
352            .iter()
353            .map(|w| w.abs())
354            .fold(0.0, Scalar::max);
355        hom = next;
356        hw = next_w;
357    }
358    let [first, second, third, fourth] = quotient_bounds(a_sup, w_sup, radius, w_min);
359    let bounds = CurveDerivativeBounds {
360        first: inflate(first),
361        second: inflate(second),
362        third: inflate(third),
363    };
364    let chord = inflate((a_sup[2] + radius * w_sup[2]) / w_min).min(bounds.second);
365    (bounds.first.is_finite()
366        && bounds.second.is_finite()
367        && bounds.third.is_finite()
368        && chord.is_finite())
369    .then_some((bounds, chord, inflate(fourth)))
370}
371
372/// Interior knots of a B-spline where it may fail to be `C^k`: those of
373/// multiplicity above `degree - k`.
374fn bspline_breaks<P>(curve: &BSplineCurve<P>, k: usize) -> Vec<Scalar> {
375    let degree = usize::from(curve.degree);
376    let interior = curve.knots.len().saturating_sub(2);
377    curve
378        .knots
379        .iter()
380        .zip(&curve.multiplicities)
381        .skip(1)
382        .take(interior)
383        .filter(|(_, &m)| (m as usize) + k > degree)
384        .map(|(&knot, _)| knot)
385        .collect()
386}
387
388/// Parameters strictly inside the curve's domain where a 2D curve may fail
389/// to be `C^k` (`k >= 1`): a polyline's vertices, a B-spline's knots of
390/// multiplicity above `degree - k`, an arc-length chain's joins (where its
391/// curvature may jump; the chain is `C^1` there, but each piece is bounded
392/// on its own). Empty for the smooth families.
393#[must_use]
394pub fn continuity_breaks2(curve: &Curve2, k: usize) -> Vec<Scalar> {
395    match curve {
396        Curve2::BSpline(b) => bspline_breaks(b, k),
397        Curve2::Polyline(p) if k >= 1 => polyline_breaks(p.points.len(), p.closed),
398        // A chain is tangent-continuous at its joins by construction, but
399        // each piece is bounded on its own, so the joins are named for
400        // every `k`.
401        Curve2::Chain(c) if k >= 1 => c.joins().unwrap_or_default(),
402        // An implicit curve's cells are bounded one at a time (#249).
403        Curve2::Implicit(c) if k >= 1 => implicit::joins(c),
404        _ => Vec::new(),
405    }
406}
407
408/// [`continuity_breaks2`] for a 3D curve.
409#[must_use]
410pub fn continuity_breaks3(curve: &Curve3, k: usize) -> Vec<Scalar> {
411    match curve {
412        Curve3::BSpline(b) => bspline_breaks(b, k),
413        Curve3::Polyline(p) if k >= 1 => polyline_breaks(p.points.len(), p.closed),
414        // A chain plan's joins and the profile's seams; curvature seams of
415        // the plan for `k >= 2` (#252).
416        Curve3::Elevated(e) => crate::elevated::elevated_breaks(e, k),
417        Curve3::Banked(b) => crate::elevated::banked_breaks(b, k),
418        _ => Vec::new(),
419    }
420}
421
422fn polyline_breaks(count: usize, closed: bool) -> Vec<Scalar> {
423    let segments = if closed {
424        count
425    } else {
426        count.saturating_sub(1)
427    };
428    (1..segments).map(|k| k as Scalar).collect()
429}
430
431fn breaks_inside(breaks: &[Scalar], a: Scalar, b: Scalar) -> bool {
432    let (lo, hi) = ordered(a, b);
433    breaks.iter().any(|&t| t > lo && t < hi)
434}
435
436/// Certified derivative suprema of a 3D curve over `[a, b]`, or `None` for
437/// a family this module cannot bound.
438///
439/// Covered: lines, circles, ellipses and B-splines (rational ones too).
440#[must_use]
441pub fn curve_derivative_bounds3(
442    curve: &Curve3,
443    a: Scalar,
444    b: Scalar,
445) -> Option<CurveDerivativeBounds> {
446    if !(a.is_finite() && b.is_finite()) {
447        return None;
448    }
449    match curve {
450        Curve3::Line(line) => Some(CurveDerivativeBounds {
451            first: inflate(line.direction.length()),
452            second: 0.0,
453            third: 0.0,
454        }),
455        Curve3::Circle(c) => Some(conic_bounds(c.radius, c.radius, c.frame.x, c.frame.y, a, b)),
456        Curve3::Ellipse(e) => Some(conic_bounds(
457            e.semi_axis_x,
458            e.semi_axis_y,
459            e.frame.x,
460            e.frame.y,
461            a,
462            b,
463        )),
464        Curve3::BSpline(spline) => {
465            bspline_bounds(spline, |p| [p.x, p.y, p.z], a, b).map(|(bounds, ..)| bounds)
466        }
467        // In plan distance, plan and profile combined (#252).
468        Curve3::Elevated(e) => crate::elevated::elevated_derivative_bounds(e, a, b),
469        Curve3::Banked(banked) => crate::elevated::banked_derivative_bounds(banked, a, b),
470        _ => None,
471    }
472}
473
474/// Certified derivative suprema of a 2D curve over `[a, b]`; see
475/// [`curve_derivative_bounds3`]. Also covers sinusoids.
476#[must_use]
477pub fn curve_derivative_bounds2(
478    curve: &Curve2,
479    a: Scalar,
480    b: Scalar,
481) -> Option<CurveDerivativeBounds> {
482    if !(a.is_finite() && b.is_finite()) {
483        return None;
484    }
485    match curve {
486        Curve2::Line(line) => Some(CurveDerivativeBounds {
487            first: inflate(line.direction.length()),
488            second: 0.0,
489            third: 0.0,
490        }),
491        Curve2::Circle(c) => Some(conic_bounds(
492            c.radius,
493            c.radius,
494            lift2(c.frame.x),
495            lift2(c.frame.y),
496            a,
497            b,
498        )),
499        Curve2::Ellipse(e) => Some(conic_bounds(
500            e.semi_axis_x,
501            e.semi_axis_y,
502            lift2(e.frame.x),
503            lift2(e.frame.y),
504            a,
505            b,
506        )),
507        Curve2::BSpline(spline) => {
508            bspline_bounds(spline, |p| [p.x, p.y], a, b).map(|(bounds, ..)| bounds)
509        }
510        // `(t, mean + a cos t + b sin t)`: every derivative's wave has
511        // amplitude `sqrt(a^2 + b^2)`.
512        Curve2::Sinusoid(w) => {
513            let amplitude = w.cosine.hypot(w.sine);
514            Some(CurveDerivativeBounds {
515                first: inflate((1.0 + amplitude * amplitude).sqrt()),
516                second: inflate(amplitude),
517                third: inflate(amplitude),
518            })
519        }
520        _ => None,
521    }
522}
523
524/// `sup |c''''|` of a 3D curve over `[a, b]` (#307), over the pieces
525/// between its [`continuity_breaks3`] as [`curve_derivative_bounds3`]'s
526/// are: zero on a line, the bound of `|c''|` on a conic (`c'''' = -c''`),
527/// the fourth hodograph on a B-spline. `None` for every other family.
528#[must_use]
529pub(crate) fn curve_fourth_bound3(curve: &Curve3, a: Scalar, b: Scalar) -> Option<Scalar> {
530    if !(a.is_finite() && b.is_finite()) {
531        return None;
532    }
533    match curve {
534        Curve3::Line(_) => Some(0.0),
535        Curve3::Circle(c) => Some(inflate(conic_second(
536            c.radius, c.radius, c.frame.x, c.frame.y, a, b,
537        ))),
538        Curve3::Ellipse(e) => Some(inflate(conic_second(
539            e.semi_axis_x,
540            e.semi_axis_y,
541            e.frame.x,
542            e.frame.y,
543            a,
544            b,
545        ))),
546        Curve3::BSpline(spline) => {
547            bspline_bounds(spline, |p| [p.x, p.y, p.z], a, b).map(|(.., fourth)| fourth)
548        }
549        _ => None,
550    }
551}
552
553/// [`curve_fourth_bound3`] for a 2D curve.
554#[must_use]
555pub(crate) fn curve_fourth_bound2(curve: &Curve2, a: Scalar, b: Scalar) -> Option<Scalar> {
556    if !(a.is_finite() && b.is_finite()) {
557        return None;
558    }
559    match curve {
560        Curve2::Line(_) => Some(0.0),
561        Curve2::Circle(c) => Some(inflate(conic_second(
562            c.radius,
563            c.radius,
564            lift2(c.frame.x),
565            lift2(c.frame.y),
566            a,
567            b,
568        ))),
569        Curve2::Ellipse(e) => Some(inflate(conic_second(
570            e.semi_axis_x,
571            e.semi_axis_y,
572            lift2(e.frame.x),
573            lift2(e.frame.y),
574            a,
575            b,
576        ))),
577        Curve2::BSpline(spline) => {
578            bspline_bounds(spline, |p| [p.x, p.y], a, b).map(|(.., fourth)| fourth)
579        }
580        _ => None,
581    }
582}
583
584/// A conic's derivatives cycle: `c' = -c'''` is `c''` a quarter turn on,
585/// so `|c'|^2 <= max(r_x^2 |X|^2, r_y^2 |Y|^2) + r_x r_y |X.Y|` bounds
586/// both; `c''` gets the interval's own tighter bound.
587fn conic_bounds(
588    rx: Scalar,
589    ry: Scalar,
590    x: Vec3,
591    y: Vec3,
592    a: Scalar,
593    b: Scalar,
594) -> CurveDerivativeBounds {
595    let speed = ((rx * rx * x.length_squared()).max(ry * ry * y.length_squared())
596        + (rx * ry * x.dot(y)).abs())
597    .sqrt();
598    CurveDerivativeBounds {
599        first: inflate(speed),
600        second: inflate(conic_second(rx, ry, x, y, a, b)),
601        third: inflate(speed),
602    }
603}
604
605/// Certified chord bound of a 2D curve over `[a, b]`.
606///
607/// An upper bound on the distance from any point of the curve between `a`
608/// and `b` to the chord joining `c(a)` and `c(b)`. `None` when the family
609/// has no derivative bound here, or when the curve may not be `C^1`
610/// strictly inside the interval (a polyline vertex, a B-spline knot of full
611/// multiplicity): split there first ([`continuity_breaks2`]).
612#[must_use]
613pub fn chord_bound2(curve: &Curve2, a: Scalar, b: Scalar) -> Option<Scalar> {
614    if !(a.is_finite() && b.is_finite()) {
615        return None;
616    }
617    if a == b {
618        return Some(0.0);
619    }
620    if breaks_inside(&continuity_breaks2(curve, 1), a, b) {
621        return None;
622    }
623    match curve {
624        Curve2::Line(_) | Curve2::Polyline(_) => Some(0.0),
625        Curve2::Circle(c) => Some(inflate(
626            circle_sagitta(c.radius, lift2(c.frame.x), lift2(c.frame.y), a, b).unwrap_or_else(
627                || {
628                    taylor(
629                        b - a,
630                        conic_second(c.radius, c.radius, lift2(c.frame.x), lift2(c.frame.y), a, b),
631                    )
632                },
633            ),
634        )),
635        Curve2::BSpline(spline) => bspline_bounds(spline, |p| [p.x, p.y], a, b)
636            .map(|(_, chord, _)| inflate(taylor(b - a, chord))),
637        Curve2::Chain(chain) => crate::chain::chain_chord_bound(chain, a, b),
638        Curve2::Implicit(c) => {
639            let (lo, hi) = ordered(a, b);
640            implicit::chord_bound(c, lo, hi).map(inflate)
641        }
642        _ => curve_derivative_bounds2(curve, a, b).map(|d| inflate(taylor(b - a, d.second))),
643    }
644}
645
646/// Certified chord bound of a 3D curve over `[a, b]`; see [`chord_bound2`].
647#[must_use]
648pub fn chord_bound3(curve: &Curve3, a: Scalar, b: Scalar) -> Option<Scalar> {
649    if !(a.is_finite() && b.is_finite()) {
650        return None;
651    }
652    if a == b {
653        return Some(0.0);
654    }
655    if breaks_inside(&continuity_breaks3(curve, 1), a, b) {
656        return None;
657    }
658    match curve {
659        Curve3::Line(_) | Curve3::Polyline(_) => Some(0.0),
660        Curve3::Circle(c) => Some(inflate(
661            circle_sagitta(c.radius, c.frame.x, c.frame.y, a, b).unwrap_or_else(|| {
662                taylor(
663                    b - a,
664                    conic_second(c.radius, c.radius, c.frame.x, c.frame.y, a, b),
665                )
666            }),
667        )),
668        Curve3::BSpline(spline) => bspline_bounds(spline, |p| [p.x, p.y, p.z], a, b)
669            .map(|(_, chord, _)| inflate(taylor(b - a, chord))),
670        // Plan and profile chord bounds at equal plan distance, composed
671        // in quadrature (#252; `crate::elevated`).
672        Curve3::Elevated(e) => crate::elevated::elevated_chord_bound(e, a, b),
673        Curve3::Banked(banked) => crate::elevated::banked_chord_bound(banked, a, b),
674        _ => curve_derivative_bounds3(curve, a, b).map(|d| inflate(taylor(b - a, d.second))),
675    }
676}
677
678/// `h^2 / 8 * sup |c''|`.
679fn taylor(h: Scalar, second: Scalar) -> Scalar {
680    h * h * 0.125 * second
681}
682
683/// Whether [`crate::curve::flatten2`] certifies its chords for this family:
684/// every point of the curve then lies within the chord tolerance of the
685/// returned polyline (#232). Lines, polylines, circles, ellipses,
686/// sinusoids, B-splines with positive weights and arc-length chains whose
687/// pieces are all bounded ([`crate::chain::chain_certifies`]); other families are
688/// flattened on their midpoint sagitta alone, which is a measurement, not a
689/// bound.
690#[must_use]
691pub fn certifies_flattening2(curve: &Curve2) -> bool {
692    match curve {
693        Curve2::Line(_)
694        | Curve2::Polyline(_)
695        | Curve2::Circle(_)
696        | Curve2::Ellipse(_)
697        | Curve2::Sinusoid(_) => true,
698        Curve2::BSpline(b) => spline_weights_positive(b.weights.as_deref()),
699        Curve2::Chain(c) => crate::chain::chain_certifies(c),
700        _ => false,
701    }
702}
703
704/// [`certifies_flattening2`] for [`crate::curve::flatten3`].
705#[must_use]
706pub fn certifies_flattening3(curve: &Curve3) -> bool {
707    match curve {
708        Curve3::Line(_) | Curve3::Polyline(_) | Curve3::Circle(_) | Curve3::Ellipse(_) => true,
709        Curve3::BSpline(b) => spline_weights_positive(b.weights.as_deref()),
710        // Certified where both halves always bound: a certified plan and
711        // a profile of closed-form pieces (#252). An intrinsic profile's
712        // bound rests on a below-vertical certificate that may run out of
713        // budget, and a banked curve's on its pivot forms: neither is
714        // claimed for every span.
715        Curve3::Elevated(e) => crate::elevated::certifies_elevated(e),
716        Curve3::Banked(_) => false,
717        _ => false,
718    }
719}
720
721fn spline_weights_positive(weights: Option<&[Scalar]>) -> bool {
722    weights.is_none_or(|w| w.iter().all(|w| w.is_finite() && *w > 0.0))
723}
724
725// --- surfaces ---------------------------------------------------------------
726
727/// Suprema of a surface's partial derivatives over a parameter box.
728///
729/// `du`, `dv` bound `|S_u|`, `|S_v|`; `duu`, `duv`, `dvv` bound the second
730/// partials. `smooth` is false when a B-spline knot line of full
731/// multiplicity crosses the box: the second-derivative bounds then hold on
732/// each side but not across, and a caller must fall back to a first-order
733/// argument there.
734#[derive(Debug, Clone, Copy, PartialEq)]
735pub struct SurfaceBounds {
736    /// `sup |S_u|`.
737    pub du: Scalar,
738    /// `sup |S_v|`.
739    pub dv: Scalar,
740    /// `sup |S_uu|`.
741    pub duu: Scalar,
742    /// `sup |S_uv|`.
743    pub duv: Scalar,
744    /// `sup |S_vv|`.
745    pub dvv: Scalar,
746    /// Whether the second-derivative bounds may be used across the box.
747    pub smooth: bool,
748    /// Coefficients `[A, B, C]` for linear interpolation over a parameter
749    /// triangle in the box: every surface point over the triangle is within
750    /// `1/2 sum_i l_i q(y_i - x)`, `q(d) = A d_u^2 + 2 B |d_u d_v| + C
751    /// d_v^2`, of the triangle spanned by the corners' surface points.
752    ///
753    /// For a polynomial surface they are `duu, duv, dvv`. For a rational
754    /// B-spline `S = A / w` they are `(|A_uu| + R |w_uu|) / w_min` and so on
755    /// (centred numerator, `R` the active points' spread): the point
756    /// `l_A / l_w` of the homogeneous interpolants is a convex combination
757    /// of the corners' surface points, so it lies in their triangle, and
758    /// `S - l_A / l_w = ((A - l_A) - (l_A / l_w)(w - l_w)) / w` carries no
759    /// first-derivative terms, unlike the quotient rule for `S_uu`.
760    pub interpolation: [Scalar; 3],
761}
762
763impl SurfaceBounds {
764    fn inflated(self) -> Self {
765        Self {
766            du: inflate(self.du),
767            dv: inflate(self.dv),
768            duu: inflate(self.duu),
769            duv: inflate(self.duv),
770            dvv: inflate(self.dvv),
771            smooth: self.smooth,
772            interpolation: self.interpolation.map(inflate),
773        }
774    }
775
776    fn finite(self) -> Option<Self> {
777        [self.du, self.dv, self.duu, self.duv, self.dvv]
778            .iter()
779            .chain(&self.interpolation)
780            .all(|v| v.is_finite())
781            .then_some(self)
782    }
783}
784
785/// Derivative bounds of one surface, prepared once for many boxes.
786///
787/// A B-spline surface's derivative control nets are built on construction,
788/// so each [`Self::bounds`] call only scans the window of the box.
789#[derive(Debug, Clone)]
790pub struct SurfaceBoundOracle<'a> {
791    surface: &'a Surface,
792    spline: Option<SplineNets>,
793}
794
795/// Homogeneous derivative nets of a B-spline surface, centred.
796#[derive(Debug, Clone)]
797struct SplineNets {
798    u_knots: Vec<Scalar>,
799    v_knots: Vec<Scalar>,
800    p: usize,
801    q: usize,
802    rows: usize,
803    cols: usize,
804    points: Vec<Vec<[Scalar; 3]>>,
805    weights: Vec<Vec<Scalar>>,
806    u_breaks: Vec<Scalar>,
807    v_breaks: Vec<Scalar>,
808}
809
810impl<'a> SurfaceBoundOracle<'a> {
811    /// Prepare the bounds of `surface`, or `None` for a family (or data)
812    /// this module cannot bound.
813    #[must_use]
814    pub fn new(surface: &'a Surface) -> Option<Self> {
815        let spline = match surface {
816            Surface::Plane(_)
817            | Surface::Cylinder(_)
818            | Surface::EllipticalCylinder(_)
819            | Surface::Cone(_)
820            | Surface::Sphere(_)
821            | Surface::Torus(_) => None,
822            Surface::BSpline(b) => Some(SplineNets::new(b)?),
823            _ => return None,
824        };
825        Some(Self { surface, spline })
826    }
827
828    /// Bounds over the box `[u.0, u.1] x [v.0, v.1]`; `None` where the
829    /// surface is not defined (a cone past its apex) or the data is not
830    /// finite.
831    #[must_use]
832    pub fn bounds(&self, u: (Scalar, Scalar), v: (Scalar, Scalar)) -> Option<SurfaceBounds> {
833        let (u0, u1) = ordered(u.0, u.1);
834        let (v0, v1) = ordered(v.0, v.1);
835        if ![u0, u1, v0, v1].iter().all(|x| x.is_finite()) {
836            return None;
837        }
838        let raw = match self.surface {
839            Surface::Plane(p) => SurfaceBounds {
840                du: p.frame.x.length(),
841                dv: p.frame.y.length(),
842                duu: 0.0,
843                duv: 0.0,
844                dvv: 0.0,
845                smooth: true,
846                interpolation: [0.0; 3],
847            },
848            Surface::Cylinder(c) => {
849                let s = frame_stretch3(&c.frame);
850                SurfaceBounds {
851                    du: s * c.radius.abs(),
852                    dv: c.frame.z.length(),
853                    duu: s * c.radius.abs(),
854                    duv: 0.0,
855                    dvv: 0.0,
856                    smooth: true,
857                    interpolation: [0.0; 3],
858                }
859            }
860            Surface::EllipticalCylinder(c) => {
861                let s = frame_stretch3(&c.frame);
862                let r = c.semi_axis_x.abs().max(c.semi_axis_y.abs());
863                SurfaceBounds {
864                    du: s * r,
865                    dv: c.frame.z.length(),
866                    duu: s * r,
867                    duv: 0.0,
868                    dvv: 0.0,
869                    smooth: true,
870                    interpolation: [0.0; 3],
871                }
872            }
873            Surface::Cone(c) => {
874                let s = frame_stretch3(&c.frame);
875                let slope = c.semi_angle.tan();
876                let (r0, r1) = (c.radius + v0 * slope, c.radius + v1 * slope);
877                if r0 < 0.0 || r1 < 0.0 {
878                    return None;
879                }
880                let rho = r0.max(r1);
881                SurfaceBounds {
882                    du: s * rho,
883                    dv: s * (1.0 + slope * slope).sqrt(),
884                    duu: s * rho,
885                    duv: s * slope.abs(),
886                    dvv: 0.0,
887                    smooth: true,
888                    interpolation: [0.0; 3],
889                }
890            }
891            Surface::Sphere(sphere) => {
892                let s = frame_stretch3(&sphere.frame) * sphere.radius.abs();
893                let cos = max_abs_cos(v0, v1);
894                SurfaceBounds {
895                    du: s * cos,
896                    dv: s,
897                    duu: s * cos,
898                    duv: s * max_abs_sin(v0, v1),
899                    dvv: s,
900                    smooth: true,
901                    interpolation: [0.0; 3],
902                }
903            }
904            Surface::Torus(t) => {
905                let s = frame_stretch3(&t.frame);
906                let (cmin, cmax) = cos_range(v0, v1);
907                let r = t.minor_radius.abs();
908                let ring = (t.major_radius + r * cmin)
909                    .abs()
910                    .max((t.major_radius + r * cmax).abs());
911                SurfaceBounds {
912                    du: s * ring,
913                    dv: s * r,
914                    duu: s * ring,
915                    duv: s * r * max_abs_sin(v0, v1),
916                    dvv: s * r,
917                    smooth: true,
918                    interpolation: [0.0; 3],
919                }
920            }
921            Surface::BSpline(_) => self.spline.as_ref()?.bounds(u0, u1, v0, v1)?,
922            _ => return None,
923        };
924        let raw = if matches!(self.surface, Surface::BSpline(_)) {
925            raw
926        } else {
927            SurfaceBounds {
928                interpolation: [raw.duu, raw.duv, raw.dvv],
929                ..raw
930            }
931        };
932        raw.inflated().finite()
933    }
934}
935
936impl SplineNets {
937    fn new(b: &BSplineSurface) -> Option<Self> {
938        let rows = b.control_points.len();
939        let cols = b.control_points.first()?.len();
940        if b.control_points.iter().any(|row| row.len() != cols) {
941            return None;
942        }
943        let ua = SplineAxis::new(&b.u_knots, &b.u_multiplicities, b.u_degree, rows, "u").ok()?;
944        let va = SplineAxis::new(&b.v_knots, &b.v_multiplicities, b.v_degree, cols, "v").ok()?;
945        let weights: Vec<Vec<Scalar>> = match &b.weights {
946            Some(net) => {
947                if net.len() != rows || net.iter().any(|row| row.len() != cols) {
948                    return None;
949                }
950                net.clone()
951            }
952            None => vec![vec![1.0; cols]; rows],
953        };
954        if weights
955            .iter()
956            .flatten()
957            .any(|w| !(w.is_finite() && *w > 0.0))
958        {
959            return None;
960        }
961        let points: Vec<Vec<[Scalar; 3]>> = b
962            .control_points
963            .iter()
964            .map(|row| row.iter().map(|p| [p.x, p.y, p.z]).collect())
965            .collect();
966        if points.iter().flatten().flatten().any(|c| !c.is_finite()) {
967            return None;
968        }
969        let breaks = |knots: &[Scalar], mults: &[u32], degree: u16| {
970            let interior = knots.len().saturating_sub(2);
971            knots
972                .iter()
973                .zip(mults)
974                .skip(1)
975                .take(interior)
976                .filter(|(_, &m)| m as usize >= usize::from(degree))
977                .map(|(&k, _)| k)
978                .collect::<Vec<_>>()
979        };
980        Some(Self {
981            u_breaks: breaks(&b.u_knots, &b.u_multiplicities, b.u_degree),
982            v_breaks: breaks(&b.v_knots, &b.v_multiplicities, b.v_degree),
983            u_knots: ua.knots,
984            v_knots: va.knots,
985            p: ua.degree,
986            q: va.degree,
987            rows,
988            cols,
989            points,
990            weights,
991        })
992    }
993
994    /// Bounds over a box: centre the active net, take its derivative nets
995    /// in the window and apply the quotient rule.
996    #[allow(clippy::needless_range_loop)]
997    fn bounds(&self, u0: Scalar, u1: Scalar, v0: Scalar, v1: Scalar) -> Option<SurfaceBounds> {
998        let clamp = |x: Scalar, knots: &[Scalar], d: usize, n: usize| x.clamp(knots[d], knots[n]);
999        let (cu0, cu1) = (
1000            clamp(u0, &self.u_knots, self.p, self.rows),
1001            clamp(u1, &self.u_knots, self.p, self.rows),
1002        );
1003        let (cv0, cv1) = (
1004            clamp(v0, &self.v_knots, self.q, self.cols),
1005            clamp(v1, &self.v_knots, self.q, self.cols),
1006        );
1007        let su0 = span_in(&self.u_knots, self.rows, self.p, cu0);
1008        let su1 = span_in(&self.u_knots, self.rows, self.p, cu1);
1009        let sv0 = span_in(&self.v_knots, self.cols, self.q, cv0);
1010        let sv1 = span_in(&self.v_knots, self.cols, self.q, cv1);
1011        let (r0, r1) = (su0 - self.p, su1);
1012        let (c0, c1) = (sv0 - self.q, sv1);
1013
1014        // Centre the active points.
1015        let mut low = [Scalar::INFINITY; 3];
1016        let mut high = [Scalar::NEG_INFINITY; 3];
1017        for row in &self.points[r0..=r1] {
1018            for p in &row[c0..=c1] {
1019                for k in 0..3 {
1020                    low[k] = low[k].min(p[k]);
1021                    high[k] = high[k].max(p[k]);
1022                }
1023            }
1024        }
1025        let centre: [Scalar; 3] = core::array::from_fn(|k| 0.5 * (low[k] + high[k]));
1026        let mut radius: Scalar = 0.0;
1027        let mut w_min = Scalar::INFINITY;
1028        // The window plus the rows and columns a second hodograph reads.
1029        let (wr0, wr1) = (r0, r1.min(self.rows - 1));
1030        let (wc0, wc1) = (c0, c1.min(self.cols - 1));
1031        let mut hom: Vec<Vec<[Scalar; 4]>> = Vec::with_capacity(wr1 + 1 - wr0);
1032        for i in wr0..=wr1 {
1033            let mut row = Vec::with_capacity(wc1 + 1 - wc0);
1034            for j in wc0..=wc1 {
1035                let p = self.points[i][j];
1036                let w = self.weights[i][j];
1037                let d: [Scalar; 3] = core::array::from_fn(|k| p[k] - centre[k]);
1038                radius = radius.max(norm(&d));
1039                w_min = w_min.min(w);
1040                row.push([d[0] * w, d[1] * w, d[2] * w, w]);
1041            }
1042            hom.push(row);
1043        }
1044        // Derivatives along u act on rows, along v on columns; the knot
1045        // slices are offset by the window start.
1046        let du_net =
1047            |net: &Vec<Vec<[Scalar; 4]>>, knots: &[Scalar], degree: usize, offset: usize| {
1048                let mut out = Vec::new();
1049                for i in 0..net.len().saturating_sub(1) {
1050                    let width = knots[offset + i + degree + 1] - knots[offset + i + 1];
1051                    let f = if width > 0.0 {
1052                        degree as Scalar / width
1053                    } else {
1054                        0.0
1055                    };
1056                    out.push(
1057                        net[i]
1058                            .iter()
1059                            .zip(&net[i + 1])
1060                            .map(|(a, b)| core::array::from_fn(|k| (b[k] - a[k]) * f))
1061                            .collect::<Vec<[Scalar; 4]>>(),
1062                    );
1063                }
1064                out
1065            };
1066        let dv_net =
1067            |net: &Vec<Vec<[Scalar; 4]>>, knots: &[Scalar], degree: usize, offset: usize| {
1068                net.iter()
1069                    .map(|row| {
1070                        (0..row.len().saturating_sub(1))
1071                            .map(|j| {
1072                                let width = knots[offset + j + degree + 1] - knots[offset + j + 1];
1073                                let f = if width > 0.0 {
1074                                    degree as Scalar / width
1075                                } else {
1076                                    0.0
1077                                };
1078                                core::array::from_fn(|k| (row[j + 1][k] - row[j][k]) * f)
1079                            })
1080                            .collect::<Vec<[Scalar; 4]>>()
1081                    })
1082                    .collect::<Vec<_>>()
1083            };
1084        let sup = |net: &Vec<Vec<[Scalar; 4]>>| {
1085            let mut a: Scalar = 0.0;
1086            let mut w: Scalar = 0.0;
1087            for p in net.iter().flatten() {
1088                a = a.max((p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt());
1089                w = w.max(p[3].abs());
1090            }
1091            (a, w)
1092        };
1093        // The k-th u-hodograph uses the knots of the (k-1)-th trimmed at
1094        // both ends; in full-vector indexing its knot offset grows by one.
1095        let (p, q) = (self.p, self.q);
1096        let nu = if p >= 1 {
1097            du_net(&hom, &self.u_knots, p, wr0)
1098        } else {
1099            Vec::new()
1100        };
1101        let nuu = if p >= 2 {
1102            du_net(&nu, &self.u_knots, p - 1, wr0 + 1)
1103        } else {
1104            Vec::new()
1105        };
1106        let nv = if q >= 1 {
1107            dv_net(&hom, &self.v_knots, q, wc0)
1108        } else {
1109            Vec::new()
1110        };
1111        let nvv = if q >= 2 {
1112            dv_net(&nv, &self.v_knots, q - 1, wc0 + 1)
1113        } else {
1114            Vec::new()
1115        };
1116        let nuv = if p >= 1 && q >= 1 {
1117            dv_net(&nu, &self.v_knots, q, wc0)
1118        } else {
1119            Vec::new()
1120        };
1121        let (au, wu) = sup(&nu);
1122        let (av, wv) = sup(&nv);
1123        let (auu, wuu) = sup(&nuu);
1124        let (avv, wvv) = sup(&nvv);
1125        let (auv, wuv) = sup(&nuv);
1126        if !(w_min.is_finite() && w_min > 0.0) {
1127            return None;
1128        }
1129        // S = A / w: S_u = (A_u - w_u S) / w, S_uu = (A_uu - 2 w_u S_u -
1130        // w_uu S) / w, S_uv = (A_uv - w_u S_v - w_v S_u - w_uv S) / w.
1131        let su = (au + wu * radius) / w_min;
1132        let sv = (av + wv * radius) / w_min;
1133        let suu = (auu + 2.0 * wu * su + wuu * radius) / w_min;
1134        let svv = (avv + 2.0 * wv * sv + wvv * radius) / w_min;
1135        let suv = (auv + wu * sv + wv * su + wuv * radius) / w_min;
1136        // Evaluation clamps to the domain, which kinks the surface at its
1137        // edge, so a box reaching past it is not smooth either.
1138        let inside = cu0 == u0 && cu1 == u1 && cv0 == v0 && cv1 == v1;
1139        let smooth = inside
1140            && !breaks_inside(&self.u_breaks, cu0, cu1)
1141            && !breaks_inside(&self.v_breaks, cv0, cv1);
1142        Some(SurfaceBounds {
1143            du: su,
1144            dv: sv,
1145            duu: suu,
1146            duv: suv,
1147            dvv: svv,
1148            smooth,
1149            interpolation: [
1150                (auu + radius * wuu) / w_min,
1151                (auv + radius * wuv) / w_min,
1152                (avv + radius * wvv) / w_min,
1153            ],
1154        })
1155    }
1156}
1157
1158#[cfg(test)]
1159mod tests;