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 NurbsSurface {
206    /// Continue this surface past one boundary of its parameter square by
207    /// `delta_param`, as the analytic continuation of its own terminal Bézier
208    /// span. See the module comment for the construction and for what survives
209    /// of the input (everything, over the original domain).
210    pub fn extend_natural(
211        &self,
212        side: SurfaceSide,
213        delta_param: f64,
214    ) -> Result<NurbsSurface, ExtendRefusal> {
215        if !delta_param.is_finite() || delta_param <= KNOT_IDENTITY_TOL {
216            return Err(ExtendRefusal::DegenerateIncrement {
217                delta: delta_param,
218            });
219        }
220        let along_u = side.is_u();
221        let (closed_u, closed_v) = self.closed_directions().map_err(ExtendRefusal::Failed)?;
222        if (along_u && closed_u) || (!along_u && closed_v) {
223            return Err(ExtendRefusal::ClosedDirection {
224                direction_u: along_u,
225            });
226        }
227
228        let (degree, knots) = if along_u {
229            (self.degree_u, &self.knots_u)
230        } else {
231            (self.degree_v, &self.knots_v)
232        };
233        let extended_knots = extend_knots(knots, degree, side.is_low(), delta_param)?;
234
235        // Control LINES along the extended direction: the columns of the net
236        // when extending u, its rows when extending v.
237        let rows = self.control_points.len();
238        let columns = self.control_points[0].len();
239        let lines: Vec<Vec<Vec4>> = if along_u {
240            (0..columns)
241                .map(|column| (0..rows).map(|row| self.control_points[row][column]).collect())
242                .collect()
243        } else {
244            self.control_points.clone()
245        };
246        let mut strips: Vec<Vec<Vec4>> = Vec::with_capacity(lines.len());
247        for line in &lines {
248            strips.push(extrapolate_line(
249                line,
250                knots,
251                degree,
252                side.is_low(),
253                delta_param,
254            )?);
255        }
256
257        // The strip's control lines, indexed the way the net is: outermost
258        // last on the high side, outermost FIRST on the low side (control
259        // points run in increasing parameter either way).
260        let mut extended: Vec<Vec<Vec4>> = if along_u {
261            let mut net: Vec<Vec<Vec4>> = Vec::with_capacity(rows + degree);
262            if side.is_low() {
263                for step in 0..degree {
264                    net.push(strips.iter().map(|strip| strip[step]).collect());
265                }
266            }
267            net.extend(self.control_points.iter().cloned());
268            if !side.is_low() {
269                for step in 0..degree {
270                    net.push(strips.iter().map(|strip| strip[step]).collect());
271                }
272            }
273            net
274        } else {
275            self.control_points
276                .iter()
277                .zip(&strips)
278                .map(|(row, strip)| {
279                    let mut line: Vec<Vec4> = Vec::with_capacity(columns + degree);
280                    if side.is_low() {
281                        line.extend_from_slice(strip);
282                        line.extend_from_slice(row);
283                    } else {
284                        line.extend_from_slice(row);
285                        line.extend_from_slice(strip);
286                    }
287                    line
288                })
289                .collect()
290        };
291
292        // Every APPENDED control point must be a usable homogeneous point.
293        // The originals are the input's and were validated when it was built.
294        let appended_rows: Vec<usize> = if along_u {
295            if side.is_low() {
296                (0..degree).collect()
297            } else {
298                (extended.len() - degree..extended.len()).collect()
299            }
300        } else {
301            (0..extended.len()).collect()
302        };
303        let appended_columns: Vec<usize> = if along_u {
304            (0..columns).collect()
305        } else if side.is_low() {
306            (0..degree).collect()
307        } else {
308            (columns..columns + degree).collect()
309        };
310        for &row in &appended_rows {
311            for &column in &appended_columns {
312                let point = extended[row][column];
313                if !point.w.is_finite() || point.w <= WEIGHT_EPS {
314                    return Err(ExtendRefusal::InvalidWeight {
315                        weight: point.w,
316                        site: WeightSite::Control { row, column },
317                    });
318                }
319                if ![point.x, point.y, point.z].iter().all(|v| v.is_finite()) {
320                    return Err(ExtendRefusal::InvalidWeight {
321                        weight: f64::NAN,
322                        site: WeightSite::Control { row, column },
323                    });
324                }
325            }
326        }
327
328        let (degree_u, degree_v) = (self.degree_u, self.degree_v);
329        let (knots_u, knots_v) = if along_u {
330            (extended_knots, self.knots_v.clone())
331        } else {
332            (self.knots_u.clone(), extended_knots)
333        };
334        let net = std::mem::take(&mut extended);
335        let candidate = NurbsSurface::new(degree_u, degree_v, knots_u, knots_v, net)
336            .map_err(ExtendRefusal::Failed)?;
337        // Before anything EVALUATES the extension: a positive control net does
338        // not make a positive denominator.
339        check_weight_field(&candidate, side, delta_param)?;
340        check_fold(self, &candidate, side, delta_param)?;
341        check_growth(self, &candidate, side, delta_param)?;
342        Ok(candidate)
343    }
344}
345
346/// The extended knot vector. The original knots survive at their original
347/// VALUES — one copy of the clamped end is re-used as the new interior knot of
348/// multiplicity `degree`, which is what lets the appended span carry the same
349/// polynomial without refining anything that was already there.
350fn extend_knots(
351    knots: &[f64],
352    degree: usize,
353    low: bool,
354    delta: f64,
355) -> Result<Vec<f64>, ExtendRefusal> {
356    let [start, end] = knot_domain(knots, degree);
357    let boundary = if low { start } else { end };
358    let multiplicity = knots
359        .iter()
360        .filter(|knot| (**knot - boundary).abs() <= KNOT_IDENTITY_TOL)
361        .count();
362    if multiplicity != degree + 1 {
363        return Err(ExtendRefusal::UnsupportedKnots {
364            multiplicity,
365            expected: degree + 1,
366        });
367    }
368    let mut extended = Vec::with_capacity(knots.len() + degree);
369    if low {
370        extended.extend(std::iter::repeat_n(start - delta, degree + 1));
371        extended.extend_from_slice(&knots[1..]);
372    } else {
373        extended.extend_from_slice(&knots[..knots.len() - 1]);
374        extended.extend(std::iter::repeat_n(end + delta, degree + 1));
375    }
376    Ok(extended)
377}
378
379/// One control line's `degree` new control points, in increasing parameter
380/// order. See the module comment for the two anti-diagonals.
381fn extrapolate_line(
382    line: &[Vec4],
383    knots: &[f64],
384    degree: usize,
385    low: bool,
386    delta: f64,
387) -> Result<Vec<Vec4>, ExtendRefusal> {
388    let curve = NurbsCurve::new(degree, knots.to_vec(), line.to_vec())
389        .map_err(ExtendRefusal::Failed)?;
390    let [start, end] = knot_domain(knots, degree);
391    // The terminal Bézier span's far knot: the last knot before the end on the
392    // high side, the first knot after the start on the low side. With no
393    // interior knot at all the surface is already a single Bézier span and the
394    // far knot is the opposite boundary.
395    let (interval_start, interval_end) = if low {
396        let next = knots
397            .iter()
398            .copied()
399            .find(|knot| *knot > start + KNOT_IDENTITY_TOL)
400            .unwrap_or(end);
401        (start, next)
402    } else {
403        let previous = knots
404            .iter()
405            .rev()
406            .copied()
407            .find(|knot| *knot < end - KNOT_IDENTITY_TOL)
408            .unwrap_or(start);
409        (previous, end)
410    };
411    // Raising the far knot to multiplicity `degree` isolates the terminal span
412    // as a Bézier segment. `insert_knot` clamps the request to what is missing,
413    // so a span that is already Bézier is returned untouched.
414    let split_at = if low { interval_end } else { interval_start };
415    let refined = curve
416        .insert_knot(split_at, degree)
417        .map_err(ExtendRefusal::Failed)?;
418    let bezier: Vec<Vec4> = if low {
419        refined.control_points[..=degree].to_vec()
420    } else {
421        refined.control_points[refined.control_points.len() - degree - 1..].to_vec()
422    };
423    let span = interval_end - interval_start;
424    if !(span > 0.0) {
425        return Err(ExtendRefusal::Failed(format!(
426            "NurbsSurface::extend_natural: terminal span [{interval_start}, {interval_end}] \
427             has no width"
428        )));
429    }
430    // x = (1 - ratio)·a + ratio·b, for x one increment outside the span.
431    let ratio = if low {
432        -delta / span
433    } else {
434        1.0 + delta / span
435    };
436    let mut triangle: Vec<Vec<Vec4>> = Vec::with_capacity(degree + 1);
437    triangle.push(bezier);
438    for level in 1..=degree {
439        let previous = &triangle[level - 1];
440        let next: Vec<Vec4> = (0..previous.len() - 1)
441            .map(|index| {
442                previous[index]
443                    .scale(1.0 - ratio)
444                    .add(previous[index + 1].scale(ratio))
445            })
446            .collect();
447        triangle.push(next);
448    }
449    Ok(if low {
450        // eᵢ = f(x^{p-i}, a^i) = d₀^{(p-i)}; the new points are e₀ … e_{p-1}
451        // (e_p is the line's existing first control point).
452        (0..degree).map(|i| triangle[degree - i][0]).collect()
453    } else {
454        // cᵢ = f(b^{p-i}, x^i) = d_{p-i}^{(i)}; the new points are c₁ … c_p
455        // (c₀ is the line's existing last control point).
456        (1..=degree).map(|i| triangle[i][degree - i]).collect()
457    })
458}
459
460/// The rational denominator must stay positive across the whole appended span,
461/// not merely at its control points. A weight polynomial interpolates its
462/// control weights and can dip below them, so this samples the span itself —
463/// the only check that catches an extension whose surface is singular INSIDE.
464fn check_weight_field(
465    extended: &NurbsSurface,
466    side: SurfaceSide,
467    delta: f64,
468) -> Result<(), ExtendRefusal> {
469    let along_u = side.is_u();
470    let [u0, u1] = extended.domain_u().map_err(ExtendRefusal::Failed)?;
471    let [v0, v1] = extended.domain_v().map_err(ExtendRefusal::Failed)?;
472    // The appended span, in the extended surface's own domain.
473    let (from, to) = if side.is_low() {
474        let start = if along_u { u0 } else { v0 };
475        (start, start + delta)
476    } else {
477        let end = if along_u { u1 } else { v1 };
478        (end - delta, end)
479    };
480    for cross in cross_stations(extended, along_u)? {
481        for index in 0..=CROSS_SAMPLES {
482            let at = from + (to - from) * index as f64 / CROSS_SAMPLES as f64;
483            let (u, v) = if along_u { (at, cross) } else { (cross, at) };
484            let weight = extended
485                .evaluate_homogeneous(u, v)
486                .map_err(ExtendRefusal::Failed)?
487                .w;
488            if !weight.is_finite() || weight <= WEIGHT_EPS {
489                return Err(ExtendRefusal::InvalidWeight {
490                    weight,
491                    site: WeightSite::Parameter { u, v },
492                });
493            }
494        }
495    }
496    Ok(())
497}
498
499/// Parameters at which the fold and growth measures cross the OTHER direction.
500fn cross_stations(surface: &NurbsSurface, along_u: bool) -> Result<Vec<f64>, ExtendRefusal> {
501    let [low, high] = if along_u {
502        surface.domain_v().map_err(ExtendRefusal::Failed)?
503    } else {
504        surface.domain_u().map_err(ExtendRefusal::Failed)?
505    };
506    Ok((0..CROSS_SAMPLES)
507        .map(|index| low + (high - low) * index as f64 / (CROSS_SAMPLES - 1) as f64)
508        .collect())
509}
510
511/// The cross-boundary tangent must not turn past `MAXIMUM_FOLD_ANGLE` anywhere
512/// along the extension. Measured against the ORIGINAL surface's own boundary
513/// tangent, so a fold is reported relative to the thing being continued.
514fn check_fold(
515    original: &NurbsSurface,
516    extended: &NurbsSurface,
517    side: SurfaceSide,
518    delta: f64,
519) -> Result<(), ExtendRefusal> {
520    let along_u = side.is_u();
521    let [start, end] = if along_u {
522        original.domain_u().map_err(ExtendRefusal::Failed)?
523    } else {
524        original.domain_v().map_err(ExtendRefusal::Failed)?
525    };
526    let boundary = if side.is_low() { start } else { end };
527    let direction = if side.is_low() { -1.0 } else { 1.0 };
528    let mut worst = 0.0f64;
529    for cross in cross_stations(original, along_u)? {
530        let reference = cross_tangent(original, along_u, boundary, cross)?;
531        let Ok(reference) = reference.normalized() else {
532            continue;
533        };
534        for fraction in FOLD_STATIONS {
535            let at = boundary + direction * delta * fraction;
536            let tangent = cross_tangent(extended, along_u, at, cross)?;
537            let Ok(tangent) = tangent.normalized() else {
538                return Err(ExtendRefusal::Fold {
539                    angle: std::f64::consts::PI,
540                    limit: MAXIMUM_FOLD_ANGLE,
541                });
542            };
543            let angle = reference.dot(tangent).clamp(-1.0, 1.0).acos();
544            worst = worst.max(angle);
545        }
546    }
547    if worst > MAXIMUM_FOLD_ANGLE {
548        return Err(ExtendRefusal::Fold {
549            angle: worst,
550            limit: MAXIMUM_FOLD_ANGLE,
551        });
552    }
553    Ok(())
554}
555
556fn cross_tangent(
557    surface: &NurbsSurface,
558    along_u: bool,
559    at: f64,
560    cross: f64,
561) -> Result<Vec3, ExtendRefusal> {
562    let (u, v) = if along_u { (at, cross) } else { (cross, at) };
563    let derivatives = surface
564        .derivatives(u, v, 1)
565        .map_err(ExtendRefusal::Failed)?;
566    Ok(if along_u {
567        derivatives[1][0]
568    } else {
569        derivatives[0][1]
570    })
571}
572
573/// The appended strip may not be longer than the surface it continues. The
574/// growth ratio is the strip's largest 3D reach over the surface's largest 3D
575/// extent in the same direction — a degenerate cross station (a pole, where the
576/// extent collapses) therefore cannot inflate the ratio.
577fn check_growth(
578    original: &NurbsSurface,
579    extended: &NurbsSurface,
580    side: SurfaceSide,
581    delta: f64,
582) -> Result<(), ExtendRefusal> {
583    let along_u = side.is_u();
584    let [start, end] = if along_u {
585        original.domain_u().map_err(ExtendRefusal::Failed)?
586    } else {
587        original.domain_v().map_err(ExtendRefusal::Failed)?
588    };
589    let boundary = if side.is_low() { start } else { end };
590    let outside = if side.is_low() {
591        start - delta
592    } else {
593        end + delta
594    };
595    let evaluate = |surface: &NurbsSurface, at: f64, cross: f64| -> Result<Vec3, ExtendRefusal> {
596        let (u, v) = if along_u { (at, cross) } else { (cross, at) };
597        surface.evaluate(u, v).map_err(ExtendRefusal::Failed)
598    };
599    let mut reach = 0.0f64;
600    let mut extent = 0.0f64;
601    for cross in cross_stations(original, along_u)? {
602        let anchor = evaluate(original, boundary, cross)?;
603        reach = reach.max(evaluate(extended, outside, cross)?.sub(anchor).length());
604        let mut length = 0.0;
605        let mut previous = evaluate(original, start, cross)?;
606        for index in 1..EXTENT_SAMPLES {
607            let at = start + (end - start) * index as f64 / (EXTENT_SAMPLES - 1) as f64;
608            let point = evaluate(original, at, cross)?;
609            length += point.sub(previous).length();
610            previous = point;
611        }
612        extent = extent.max(length);
613    }
614    if extent <= 0.0 {
615        return Err(ExtendRefusal::Failed(
616            "NurbsSurface::extend_natural: the surface has no extent in the extended direction"
617                .into(),
618        ));
619    }
620    let ratio = reach / extent;
621    if ratio > MAXIMUM_GROWTH_RATIO {
622        return Err(ExtendRefusal::ExcessiveGrowth {
623            ratio,
624            limit: MAXIMUM_GROWTH_RATIO,
625        });
626    }
627    Ok(())
628}
629