Skip to main content

brep_kernel/geometry/surface/
extension.rs

1//! Natural extension of a NURBS surface: the terminal Bézier span of one
2//! parameter direction, extrapolated by a bounded parameter increment and
3//! appended as a fresh span.
4//!
5//! This is the real answer to the question `evaluate_extended` only pretends
6//! to answer. `evaluate_extended` continues an open direction along the
7//! boundary TANGENT PLANE, without bound and without a surface: a solver that
8//! has no solution slides onto that fictitious plane and converges there, far
9//! outside the patch, with nothing downstream able to tell. An extension built
10//! here is a `NurbsSurface` — the analytic continuation of the terminal
11//! polynomial, with a domain that ends somewhere, so a solve that leaves the
12//! extension leaves the surface and is refused rather than answered.
13//!
14//! The construction (Piegl & Tiller §5.3 knot insertion, plus the blossom form
15//! of de Casteljau) is:
16//!
17//! 1. Refine a scratch copy so the last interior knot reaches multiplicity
18//!    `degree`; the terminal `degree + 1` control points of each control line
19//!    are then the Bézier control points of the terminal span `[a, b]`.
20//! 2. Run one de Casteljau triangle in HOMOGENEOUS coordinates at the
21//!    off-interval parameter, and read the new span's control points off an
22//!    anti-diagonal of it. For the high side the continuation over `[b, b + δ]`
23//!    is `cᵢ = f(b^{p-i}, x^i)` with `x = b + δ`; for the low side the
24//!    continuation over `[a - δ, a]` is `eᵢ = f(x^{p-i}, a^i)` with
25//!    `x = a - δ`. Both are one triangle with a single ratio.
26//! 3. Append (or prepend) those `degree` control lines and the matching knots.
27//!
28//! The emitted surface keeps the input's knots and control net VERBATIM over
29//! the original domain — nothing is refined in the output — so the original
30//! parameterization survives exactly. Two things follow: evaluation inside the
31//! old domain is bit-identical to the input's, and every pcurve already drawn
32//! on the face stays valid with no remap (unlike the analytic
33//! `extend_ruled_carrier`, which rescales v and has to walk the coedges).
34//!
35//! Every refusal is typed and carries the quantity that was measured.
36
37use super::NurbsSurface;
38use crate::curve::{knot_domain, KNOT_IDENTITY_TOL};
39use crate::{NurbsCurve, Vec3, Vec4};
40
41/// Smallest weight an extrapolated control point may carry. Matches
42/// `NurbsSurface::ensure_valid`'s own `EPS` gate, so a strip this accepts is a
43/// strip the constructor accepts.
44const WEIGHT_EPS: f64 = 1e-12;
45
46/// The largest angle the extension's cross-boundary tangent may turn through
47/// before the extension is called a FOLD. π/2 is the sign change: past it the
48/// extension has stopped going outwards and is doubling back over the patch it
49/// came from, which is never a usable carrier.
50pub const MAXIMUM_FOLD_ANGLE: f64 = std::f64::consts::FRAC_PI_2;
51
52/// The largest the appended strip may be, as a multiple of the surface's own
53/// 3D extent in the direction being extended: the extension may not be longer
54/// than the surface it continues. A polynomial extrapolated by a fraction `r`
55/// of its own span diverges like `r^degree`, so this is a backstop and not a
56/// quality bar — callers that need a trustworthy extension ask for a small
57/// increment and check the result, they do not lean on this limit.
58pub const MAXIMUM_GROWTH_RATIO: f64 = 1.0;
59
60/// How many stations across the far parameter direction the fold and growth
61/// measures sample.
62const CROSS_SAMPLES: usize = 9;
63/// How many stations ALONG the extension the fold measure samples.
64const FOLD_STATIONS: [f64; 4] = [0.25, 0.5, 0.75, 1.0];
65/// How many chords the growth measure uses for the surface's own extent.
66const EXTENT_SAMPLES: usize = 17;
67
68/// Where a non-positive weight was found.
69#[derive(Clone, Copy, Debug, PartialEq)]
70pub enum WeightSite {
71    /// An extrapolated CONTROL point of the appended strip, by its position in
72    /// the extended net.
73    Control { row: usize, column: usize },
74    /// A PARAMETER inside the appended span. Every control weight is positive
75    /// and the weight polynomial still dips through zero between them, which no
76    /// check on the control net alone can see.
77    Parameter { u: f64, v: f64 },
78}
79
80impl WeightSite {
81    fn describe(&self) -> String {
82        match self {
83            Self::Control { row, column } => {
84                format!("the extrapolated control point at row {row}, column {column}")
85            }
86            Self::Parameter { u, v } => format!("({u:.6}, {v:.6}) inside the appended span"),
87        }
88    }
89}
90
91/// Which boundary of the parameter square an extension grows from.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum SurfaceSide {
94    /// Below the start of the u domain.
95    UMin,
96    /// Beyond the end of the u domain.
97    UMax,
98    /// Below the start of the v domain.
99    VMin,
100    /// Beyond the end of the v domain.
101    VMax,
102}
103
104impl SurfaceSide {
105    /// Whether this side extends the u direction (rather than v).
106    pub fn is_u(self) -> bool {
107        matches!(self, Self::UMin | Self::UMax)
108    }
109
110    /// Whether this side grows BELOW the domain start (rather than beyond its
111    /// end).
112    pub fn is_low(self) -> bool {
113        matches!(self, Self::UMin | Self::VMin)
114    }
115}
116
117/// Why a natural extension produced nothing. Each variant carries what was
118/// measured, so a caller can report the number rather than the verdict.
119#[derive(Clone, Debug)]
120pub enum ExtendRefusal {
121    /// The requested direction joins itself. A closed direction has no outside
122    /// to extend INTO — it wraps — and appending a span there would overlay the
123    /// surface on itself. `direction_u` says which direction was asked for.
124    ClosedDirection { direction_u: bool },
125    /// The parameter increment is not one: not positive, not finite, or not
126    /// large enough to be a knot of its own. `KNOT_IDENTITY_TOL` is the band
127    /// within which this kernel treats two knots as THE SAME knot, so an
128    /// increment inside it appends a zero-width span whose basis functions are
129    /// all zero — an extension that evaluates to a zero-weight point everywhere
130    /// it was supposed to be new. Measured 2026-09-12: a caller asking for a
131    /// 3e-18 extension off a noise-level overrun estimate produced exactly that.
132    DegenerateIncrement { delta: f64 },
133    /// The knot vector's clamped end is not at multiplicity `degree + 1`, so
134    /// the terminal `degree + 1` control points are not the terminal Bézier
135    /// span's and the construction does not apply. Vendor exporters do emit
136    /// over-clamped ends (see `knot_find_span`); extending one needs the extra
137    /// knots stripped first, which this does not do.
138    UnsupportedKnots { multiplicity: usize, expected: usize },
139    /// The rational extension's denominator has stopped being positive — at an
140    /// extrapolated control point, or at a parameter between them. A rational
141    /// patch extrapolated far enough reaches this whenever its weight
142    /// polynomial has a real root outside the interval it was fitted on, and a
143    /// patch whose control weights are all still positive can already have
144    /// dipped through zero between them (measured on imported B-spline faces:
145    /// `abc_00000013.step` does both).
146    InvalidWeight { weight: f64, site: WeightSite },
147    /// The extension turns back on itself: the cross-boundary tangent at some
148    /// station of the extension makes `angle` with the same tangent at the
149    /// boundary, exceeding `limit`.
150    Fold { angle: f64, limit: f64 },
151    /// The appended strip reaches `ratio` times the surface's own 3D extent in
152    /// the extended direction, exceeding `limit`.
153    ExcessiveGrowth { ratio: f64, limit: f64 },
154    /// A lower-level failure (knot insertion, a surface constructor, an
155    /// evaluation), carried verbatim so the call site can prefix it with its
156    /// own operation name.
157    Failed(String),
158}
159
160impl ExtendRefusal {
161    /// The failure text, for a site with no more specific wording.
162    pub fn describe(&self) -> String {
163        match self {
164            Self::ClosedDirection { direction_u } => format!(
165                "the {} direction is closed — a closed direction wraps rather than extending",
166                if *direction_u { "u" } else { "v" }
167            ),
168            Self::DegenerateIncrement { delta } => format!(
169                "the parameter increment {delta:e} is not a usable one — it must be positive, \
170                 finite, and larger than the {KNOT_IDENTITY_TOL:e} band within which two knots \
171                 are the same knot"
172            ),
173            Self::UnsupportedKnots {
174                multiplicity,
175                expected,
176            } => format!(
177                "the extended end's knot multiplicity is {multiplicity}, not the clamped \
178                 {expected} this construction needs"
179            ),
180            Self::InvalidWeight { weight, site } => format!(
181                "the rational extension's weight is {weight:.3e} at {} — it has passed a root \
182                 of its own weight polynomial",
183                site.describe()
184            ),
185            Self::Fold { angle, limit } => format!(
186                "the extension folds back: the cross-boundary tangent turns {:.2}° \
187                 (limit {:.2}°)",
188                angle.to_degrees(),
189                limit.to_degrees()
190            ),
191            Self::ExcessiveGrowth { ratio, limit } => format!(
192                "the extension reaches {ratio:.3}× the surface's own extent (limit {limit:.3})"
193            ),
194            Self::Failed(message) => message.clone(),
195        }
196    }
197}
198
199impl std::fmt::Display for ExtendRefusal {
200    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        formatter.write_str(&self.describe())
202    }
203}
204
205impl NurbsCurve {
206    /// Continue this curve past its low (`low`) or high end by `delta_param`,
207    /// as the analytic continuation of its own terminal Bézier span: the curve
208    /// analogue of [`NurbsSurface::extend_natural`], through the same knot
209    /// and control-point construction, so a surface's isoline and the
210    /// continued isoline agree. Everything over the original domain survives.
211    /// A rational continuation whose appended weights are not positive is
212    /// refused; the caller samples the span where it needs more than that.
213    pub fn extend_natural(&self, low: bool, delta_param: f64) -> Result<NurbsCurve, ExtendRefusal> {
214        if !delta_param.is_finite() || delta_param <= KNOT_IDENTITY_TOL {
215            return Err(ExtendRefusal::DegenerateIncrement { delta: delta_param });
216        }
217        let knots = extend_knots(&self.knots, self.degree, low, delta_param)?;
218        let strip = extrapolate_line(&self.control_points, &self.knots, self.degree, low, delta_param)?;
219        if let Some(bad) = strip.iter().find(|point| !(point.w > 0.0)) {
220            return Err(ExtendRefusal::Failed(format!(
221                "NurbsCurve::extend_natural: an appended weight is {:.3e}",
222                bad.w
223            )));
224        }
225        let mut points = Vec::with_capacity(self.control_points.len() + self.degree);
226        if low {
227            points.extend_from_slice(&strip);
228            points.extend_from_slice(&self.control_points);
229        } else {
230            points.extend_from_slice(&self.control_points);
231            points.extend_from_slice(&strip);
232        }
233        NurbsCurve::new(self.degree, knots, points).map_err(ExtendRefusal::Failed)
234    }
235}
236
237impl NurbsSurface {
238    /// Continue this surface past one boundary of its parameter square by
239    /// `delta_param`, as the analytic continuation of its own terminal Bézier
240    /// span. See the module comment for the construction and for what survives
241    /// of the input (everything, over the original domain).
242    pub fn extend_natural(
243        &self,
244        side: SurfaceSide,
245        delta_param: f64,
246    ) -> Result<NurbsSurface, ExtendRefusal> {
247        if !delta_param.is_finite() || delta_param <= KNOT_IDENTITY_TOL {
248            return Err(ExtendRefusal::DegenerateIncrement {
249                delta: delta_param,
250            });
251        }
252        let along_u = side.is_u();
253        let (closed_u, closed_v) = self.closed_directions().map_err(ExtendRefusal::Failed)?;
254        if (along_u && closed_u) || (!along_u && closed_v) {
255            return Err(ExtendRefusal::ClosedDirection {
256                direction_u: along_u,
257            });
258        }
259
260        let (degree, knots) = if along_u {
261            (self.degree_u, &self.knots_u)
262        } else {
263            (self.degree_v, &self.knots_v)
264        };
265        let extended_knots = extend_knots(knots, degree, side.is_low(), delta_param)?;
266
267        // Control LINES along the extended direction: the columns of the net
268        // when extending u, its rows when extending v.
269        let rows = self.control_points.len();
270        let columns = self.control_points[0].len();
271        let lines: Vec<Vec<Vec4>> = if along_u {
272            (0..columns)
273                .map(|column| (0..rows).map(|row| self.control_points[row][column]).collect())
274                .collect()
275        } else {
276            self.control_points.clone()
277        };
278        let mut strips: Vec<Vec<Vec4>> = Vec::with_capacity(lines.len());
279        for line in &lines {
280            strips.push(extrapolate_line(
281                line,
282                knots,
283                degree,
284                side.is_low(),
285                delta_param,
286            )?);
287        }
288
289        // The strip's control lines, indexed the way the net is: outermost
290        // last on the high side, outermost FIRST on the low side (control
291        // points run in increasing parameter either way).
292        let mut extended: Vec<Vec<Vec4>> = if along_u {
293            let mut net: Vec<Vec<Vec4>> = Vec::with_capacity(rows + degree);
294            if side.is_low() {
295                for step in 0..degree {
296                    net.push(strips.iter().map(|strip| strip[step]).collect());
297                }
298            }
299            net.extend(self.control_points.iter().cloned());
300            if !side.is_low() {
301                for step in 0..degree {
302                    net.push(strips.iter().map(|strip| strip[step]).collect());
303                }
304            }
305            net
306        } else {
307            self.control_points
308                .iter()
309                .zip(&strips)
310                .map(|(row, strip)| {
311                    let mut line: Vec<Vec4> = Vec::with_capacity(columns + degree);
312                    if side.is_low() {
313                        line.extend_from_slice(strip);
314                        line.extend_from_slice(row);
315                    } else {
316                        line.extend_from_slice(row);
317                        line.extend_from_slice(strip);
318                    }
319                    line
320                })
321                .collect()
322        };
323
324        // Every APPENDED control point must be a usable homogeneous point.
325        // The originals are the input's and were validated when it was built.
326        let appended_rows: Vec<usize> = if along_u {
327            if side.is_low() {
328                (0..degree).collect()
329            } else {
330                (extended.len() - degree..extended.len()).collect()
331            }
332        } else {
333            (0..extended.len()).collect()
334        };
335        let appended_columns: Vec<usize> = if along_u {
336            (0..columns).collect()
337        } else if side.is_low() {
338            (0..degree).collect()
339        } else {
340            (columns..columns + degree).collect()
341        };
342        for &row in &appended_rows {
343            for &column in &appended_columns {
344                let point = extended[row][column];
345                if !point.w.is_finite() || point.w <= WEIGHT_EPS {
346                    return Err(ExtendRefusal::InvalidWeight {
347                        weight: point.w,
348                        site: WeightSite::Control { row, column },
349                    });
350                }
351                if ![point.x, point.y, point.z].iter().all(|v| v.is_finite()) {
352                    return Err(ExtendRefusal::InvalidWeight {
353                        weight: f64::NAN,
354                        site: WeightSite::Control { row, column },
355                    });
356                }
357            }
358        }
359
360        let (degree_u, degree_v) = (self.degree_u, self.degree_v);
361        let (knots_u, knots_v) = if along_u {
362            (extended_knots, self.knots_v.clone())
363        } else {
364            (self.knots_u.clone(), extended_knots)
365        };
366        let net = std::mem::take(&mut extended);
367        let candidate = NurbsSurface::new(degree_u, degree_v, knots_u, knots_v, net)
368            .map_err(ExtendRefusal::Failed)?;
369        // Before anything EVALUATES the extension: a positive control net does
370        // not make a positive denominator.
371        check_weight_field(&candidate, side, delta_param)?;
372        check_fold(self, &candidate, side, delta_param)?;
373        check_growth(self, &candidate, side, delta_param)?;
374        Ok(candidate)
375    }
376}
377
378/// The extended knot vector. The original knots survive at their original
379/// VALUES — one copy of the clamped end is re-used as the new interior knot of
380/// multiplicity `degree`, which is what lets the appended span carry the same
381/// polynomial without refining anything that was already there.
382fn extend_knots(
383    knots: &[f64],
384    degree: usize,
385    low: bool,
386    delta: f64,
387) -> Result<Vec<f64>, ExtendRefusal> {
388    let [start, end] = knot_domain(knots, degree);
389    let boundary = if low { start } else { end };
390    let multiplicity = knots
391        .iter()
392        .filter(|knot| (**knot - boundary).abs() <= KNOT_IDENTITY_TOL)
393        .count();
394    if multiplicity != degree + 1 {
395        return Err(ExtendRefusal::UnsupportedKnots {
396            multiplicity,
397            expected: degree + 1,
398        });
399    }
400    let mut extended = Vec::with_capacity(knots.len() + degree);
401    if low {
402        extended.extend(std::iter::repeat_n(start - delta, degree + 1));
403        extended.extend_from_slice(&knots[1..]);
404    } else {
405        extended.extend_from_slice(&knots[..knots.len() - 1]);
406        extended.extend(std::iter::repeat_n(end + delta, degree + 1));
407    }
408    Ok(extended)
409}
410
411/// One control line's `degree` new control points, in increasing parameter
412/// order. See the module comment for the two anti-diagonals.
413fn extrapolate_line(
414    line: &[Vec4],
415    knots: &[f64],
416    degree: usize,
417    low: bool,
418    delta: f64,
419) -> Result<Vec<Vec4>, ExtendRefusal> {
420    let curve = NurbsCurve::new(degree, knots.to_vec(), line.to_vec())
421        .map_err(ExtendRefusal::Failed)?;
422    let [start, end] = knot_domain(knots, degree);
423    // The terminal Bézier span's far knot: the last knot before the end on the
424    // high side, the first knot after the start on the low side. With no
425    // interior knot at all the surface is already a single Bézier span and the
426    // far knot is the opposite boundary.
427    let (interval_start, interval_end) = if low {
428        let next = knots
429            .iter()
430            .copied()
431            .find(|knot| *knot > start + KNOT_IDENTITY_TOL)
432            .unwrap_or(end);
433        (start, next)
434    } else {
435        let previous = knots
436            .iter()
437            .rev()
438            .copied()
439            .find(|knot| *knot < end - KNOT_IDENTITY_TOL)
440            .unwrap_or(start);
441        (previous, end)
442    };
443    // Raising the far knot to multiplicity `degree` isolates the terminal span
444    // as a Bézier segment. `insert_knot` clamps the request to what is missing,
445    // so a span that is already Bézier is returned untouched.
446    let split_at = if low { interval_end } else { interval_start };
447    let refined = curve
448        .insert_knot(split_at, degree)
449        .map_err(ExtendRefusal::Failed)?;
450    let bezier: Vec<Vec4> = if low {
451        refined.control_points[..=degree].to_vec()
452    } else {
453        refined.control_points[refined.control_points.len() - degree - 1..].to_vec()
454    };
455    let span = interval_end - interval_start;
456    if !(span > 0.0) {
457        return Err(ExtendRefusal::Failed(format!(
458            "NurbsSurface::extend_natural: terminal span [{interval_start}, {interval_end}] \
459             has no width"
460        )));
461    }
462    // x = (1 - ratio)·a + ratio·b, for x one increment outside the span.
463    let ratio = if low {
464        -delta / span
465    } else {
466        1.0 + delta / span
467    };
468    let mut triangle: Vec<Vec<Vec4>> = Vec::with_capacity(degree + 1);
469    triangle.push(bezier);
470    for level in 1..=degree {
471        let previous = &triangle[level - 1];
472        let next: Vec<Vec4> = (0..previous.len() - 1)
473            .map(|index| {
474                previous[index]
475                    .scale(1.0 - ratio)
476                    .add(previous[index + 1].scale(ratio))
477            })
478            .collect();
479        triangle.push(next);
480    }
481    Ok(if low {
482        // eᵢ = f(x^{p-i}, a^i) = d₀^{(p-i)}; the new points are e₀ … e_{p-1}
483        // (e_p is the line's existing first control point).
484        (0..degree).map(|i| triangle[degree - i][0]).collect()
485    } else {
486        // cᵢ = f(b^{p-i}, x^i) = d_{p-i}^{(i)}; the new points are c₁ … c_p
487        // (c₀ is the line's existing last control point).
488        (1..=degree).map(|i| triangle[i][degree - i]).collect()
489    })
490}
491
492/// The rational denominator must stay positive across the whole appended span,
493/// not merely at its control points. A weight polynomial interpolates its
494/// control weights and can dip below them, so this samples the span itself —
495/// the only check that catches an extension whose surface is singular INSIDE.
496fn check_weight_field(
497    extended: &NurbsSurface,
498    side: SurfaceSide,
499    delta: f64,
500) -> Result<(), ExtendRefusal> {
501    let along_u = side.is_u();
502    let [u0, u1] = extended.domain_u().map_err(ExtendRefusal::Failed)?;
503    let [v0, v1] = extended.domain_v().map_err(ExtendRefusal::Failed)?;
504    // The appended span, in the extended surface's own domain.
505    let (from, to) = if side.is_low() {
506        let start = if along_u { u0 } else { v0 };
507        (start, start + delta)
508    } else {
509        let end = if along_u { u1 } else { v1 };
510        (end - delta, end)
511    };
512    for cross in cross_stations(extended, along_u)? {
513        for index in 0..=CROSS_SAMPLES {
514            let at = from + (to - from) * index as f64 / CROSS_SAMPLES as f64;
515            let (u, v) = if along_u { (at, cross) } else { (cross, at) };
516            let weight = extended
517                .evaluate_homogeneous(u, v)
518                .map_err(ExtendRefusal::Failed)?
519                .w;
520            if !weight.is_finite() || weight <= WEIGHT_EPS {
521                return Err(ExtendRefusal::InvalidWeight {
522                    weight,
523                    site: WeightSite::Parameter { u, v },
524                });
525            }
526        }
527    }
528    Ok(())
529}
530
531/// Parameters at which the fold and growth measures cross the OTHER direction.
532fn cross_stations(surface: &NurbsSurface, along_u: bool) -> Result<Vec<f64>, ExtendRefusal> {
533    let [low, high] = if along_u {
534        surface.domain_v().map_err(ExtendRefusal::Failed)?
535    } else {
536        surface.domain_u().map_err(ExtendRefusal::Failed)?
537    };
538    Ok((0..CROSS_SAMPLES)
539        .map(|index| low + (high - low) * index as f64 / (CROSS_SAMPLES - 1) as f64)
540        .collect())
541}
542
543/// The cross-boundary tangent must not turn past `MAXIMUM_FOLD_ANGLE` anywhere
544/// along the extension. Measured against the ORIGINAL surface's own boundary
545/// tangent, so a fold is reported relative to the thing being continued.
546fn check_fold(
547    original: &NurbsSurface,
548    extended: &NurbsSurface,
549    side: SurfaceSide,
550    delta: f64,
551) -> Result<(), ExtendRefusal> {
552    let along_u = side.is_u();
553    let [start, end] = if along_u {
554        original.domain_u().map_err(ExtendRefusal::Failed)?
555    } else {
556        original.domain_v().map_err(ExtendRefusal::Failed)?
557    };
558    let boundary = if side.is_low() { start } else { end };
559    let direction = if side.is_low() { -1.0 } else { 1.0 };
560    let mut worst = 0.0f64;
561    for cross in cross_stations(original, along_u)? {
562        let reference = cross_tangent(original, along_u, boundary, cross)?;
563        let Ok(reference) = reference.normalized() else {
564            continue;
565        };
566        for fraction in FOLD_STATIONS {
567            let at = boundary + direction * delta * fraction;
568            let tangent = cross_tangent(extended, along_u, at, cross)?;
569            let Ok(tangent) = tangent.normalized() else {
570                return Err(ExtendRefusal::Fold {
571                    angle: std::f64::consts::PI,
572                    limit: MAXIMUM_FOLD_ANGLE,
573                });
574            };
575            let angle = reference.dot(tangent).clamp(-1.0, 1.0).acos();
576            worst = worst.max(angle);
577        }
578    }
579    if worst > MAXIMUM_FOLD_ANGLE {
580        return Err(ExtendRefusal::Fold {
581            angle: worst,
582            limit: MAXIMUM_FOLD_ANGLE,
583        });
584    }
585    Ok(())
586}
587
588fn cross_tangent(
589    surface: &NurbsSurface,
590    along_u: bool,
591    at: f64,
592    cross: f64,
593) -> Result<Vec3, ExtendRefusal> {
594    let (u, v) = if along_u { (at, cross) } else { (cross, at) };
595    let derivatives = surface
596        .derivatives(u, v, 1)
597        .map_err(ExtendRefusal::Failed)?;
598    Ok(if along_u {
599        derivatives[1][0]
600    } else {
601        derivatives[0][1]
602    })
603}
604
605/// The appended strip may not be longer than the surface it continues. The
606/// growth ratio is the strip's largest 3D reach over the surface's largest 3D
607/// extent in the same direction — a degenerate cross station (a pole, where the
608/// extent collapses) therefore cannot inflate the ratio.
609fn check_growth(
610    original: &NurbsSurface,
611    extended: &NurbsSurface,
612    side: SurfaceSide,
613    delta: f64,
614) -> Result<(), ExtendRefusal> {
615    let along_u = side.is_u();
616    let [start, end] = if along_u {
617        original.domain_u().map_err(ExtendRefusal::Failed)?
618    } else {
619        original.domain_v().map_err(ExtendRefusal::Failed)?
620    };
621    let boundary = if side.is_low() { start } else { end };
622    let outside = if side.is_low() {
623        start - delta
624    } else {
625        end + delta
626    };
627    let evaluate = |surface: &NurbsSurface, at: f64, cross: f64| -> Result<Vec3, ExtendRefusal> {
628        let (u, v) = if along_u { (at, cross) } else { (cross, at) };
629        surface.evaluate(u, v).map_err(ExtendRefusal::Failed)
630    };
631    let mut reach = 0.0f64;
632    let mut extent = 0.0f64;
633    for cross in cross_stations(original, along_u)? {
634        let anchor = evaluate(original, boundary, cross)?;
635        reach = reach.max(evaluate(extended, outside, cross)?.sub(anchor).length());
636        let mut length = 0.0;
637        let mut previous = evaluate(original, start, cross)?;
638        for index in 1..EXTENT_SAMPLES {
639            let at = start + (end - start) * index as f64 / (EXTENT_SAMPLES - 1) as f64;
640            let point = evaluate(original, at, cross)?;
641            length += point.sub(previous).length();
642            previous = point;
643        }
644        extent = extent.max(length);
645    }
646    if extent <= 0.0 {
647        return Err(ExtendRefusal::Failed(
648            "NurbsSurface::extend_natural: the surface has no extent in the extended direction"
649                .into(),
650        ));
651    }
652    let ratio = reach / extent;
653    if ratio > MAXIMUM_GROWTH_RATIO {
654        return Err(ExtendRefusal::ExcessiveGrowth {
655            ratio,
656            limit: MAXIMUM_GROWTH_RATIO,
657        });
658    }
659    Ok(())
660}
661