Skip to main content

brep_kernel/geometry/
surface.rs

1use crate::curve::{
2    basis_derivatives_into, basis_functions_into, knot_clamp, knot_domain, knot_find_span,
3    validate_knots, MAX_STACK_DEGREE, MAX_STACK_ORDER,
4};
5use crate::{KnotVector, NurbsCurve, Vec3, Vec4};
6use serde::{Deserialize, Serialize};
7
8#[path = "surface/extension.rs"]
9mod extension;
10pub use extension::{
11    ExtendRefusal, SurfaceSide, WeightSite, MAXIMUM_FOLD_ANGLE, MAXIMUM_GROWTH_RATIO,
12};
13
14const EPS: f64 = crate::tolerance::FP_FLOOR;
15/// A generatrix control point that is neither ON the axis of revolution nor
16/// this fraction of the generatrix's own radial extent clear of it is a fuzzy
17/// pole, and `make_revolution` refuses it (see the comment at the check).
18const NEAR_AXIS_RELATIVE_TOLERANCE: f64 = 1e-4;
19
20/// The widest tensor block [`NurbsSurface::deriv1_tensor_each`] will evaluate,
21/// per direction. The mass integrator reads a RATIONAL patch's cell at up to
22/// this many stations; `props::mass_properties::rule::MAX_ORDER` is DEFINED as
23/// this constant rather than repeating its value, because the block's arrays
24/// here and the station arrays there must agree and a drift between them is a
25/// hard failure, not a slow path (the callers propagate the refusal below
26/// rather than falling back). 8 was the fixed rule's own width; the arrays are
27/// stack-resident either way.
28pub(crate) const TENSOR_MAX_BLOCK: usize = 24;
29
30/// A rational B-spline surface.
31///
32/// **Invariant: do not mutate the public fields after the surface has been
33/// evaluated.** The surface memoizes derived data (its analytic carrier, seam
34/// closure, projection grids, validation) the first time it is asked, and
35/// `Clone` copies those caches. Editing `control_points`, `knots_*` or a degree
36/// in place afterwards leaves every later query answering for the OLD
37/// geometry. Build a new value with [`NurbsSurface::new`] instead (lane K2
38/// audit 2026-10-16: no production path does this today; a test fixture did).
39#[derive(Clone, Debug, Deserialize, Serialize)]
40pub struct NurbsSurface {
41    pub degree_u: usize,
42    pub degree_v: usize,
43    pub knots_u: Vec<f64>,
44    pub knots_v: Vec<f64>,
45    pub control_points: Vec<Vec<Vec4>>,
46    /// Lazily recognized analytic carrier (plane/cylinder/cone/sphere/torus).
47    /// Purely an acceleration cache: never serialized, cloned with the
48    /// surface, and recomputed on demand after deserialization.
49    #[serde(skip, default)]
50    analytic: std::cell::OnceCell<Option<crate::AnalyticSurface>>,
51    /// Lazily recognized affine sphere (`geometry/affine_sphere.rs`), read
52    /// only under `BREP_AFFINE_SPHERE` and only when no analytic carrier was
53    /// recognized; an acceleration cache like `analytic`.
54    #[serde(skip, default)]
55    affine_sphere: std::cell::OnceCell<Option<crate::affine_sphere::AffineSphere>>,
56    /// One-time validation cache (see `NurbsCurve::ensure_valid`).
57    #[serde(skip, default)]
58    validated: std::cell::Cell<bool>,
59    /// Cached (closed_u, closed_v) seam test — a pure function of the
60    /// immutable geometry, recomputed on demand after deserialization.
61    #[serde(skip, default)]
62    closed_directions: std::cell::OnceCell<(bool, bool)>,
63    /// Cached Newton seed grid for general point projection (see
64    /// `projection.rs`) — (u, v, point) samples over the knot spans.
65    #[serde(skip, default)]
66    pub(crate) projection_grid: std::cell::OnceCell<Vec<(f64, f64, Vec3)>>,
67    /// Cached dense fallback grid for degenerate general projection
68    /// (`projection.rs`) — (u, v, point) samples at fixed per-surface
69    /// parameters, so the ~1.7M-call metre-mm fallback reuses the evals.
70    #[serde(skip, default)]
71    pub(crate) projection_dense_grid: std::cell::OnceCell<Vec<(f64, f64, Vec3)>>,
72    /// Cached boundary-ring sample points for the degenerate projection
73    /// fallback (`projection.rs`), flattened as 4 sides × 22 exponents ×
74    /// 33 indices of evaluated surface points at fixed per-surface params.
75    #[serde(skip, default)]
76    pub(crate) projection_ring_grid: std::cell::OnceCell<Vec<Vec3>>,
77    /// Cached convex-hull enclosure of every projection lattice cell
78    /// (`projection.rs`'s global stage), built on the first query the local
79    /// answer does not already settle. `None` when the net cannot be refined.
80    #[serde(skip, default)]
81    pub(crate) projection_cells: std::cell::OnceCell<Option<crate::projection::ProjectionCells>>,
82}
83
84impl NurbsSurface {
85    pub fn new(
86        degree_u: usize,
87        degree_v: usize,
88        knots_u: Vec<f64>,
89        knots_v: Vec<f64>,
90        control_points: Vec<Vec<Vec4>>,
91    ) -> Result<Self, String> {
92        let surface = Self {
93            degree_u,
94            degree_v,
95            knots_u,
96            knots_v,
97            control_points,
98            analytic: std::cell::OnceCell::new(),
99            affine_sphere: std::cell::OnceCell::new(),
100            validated: std::cell::Cell::new(false),
101            closed_directions: std::cell::OnceCell::new(),
102            projection_grid: std::cell::OnceCell::new(),
103            projection_dense_grid: std::cell::OnceCell::new(),
104            projection_ring_grid: std::cell::OnceCell::new(),
105            projection_cells: std::cell::OnceCell::new(),
106        };
107        surface.ensure_valid()?;
108        Ok(surface)
109    }
110
111    /// Whether the surface joins itself along u (first) and v (second):
112    /// opposite domain edges coincide at three sampled fractions. The 1e-6
113    /// threshold matches the historical `LINEAR_TOLERANCE * 10` used by the
114    /// projection and curve×surface intersection modules.
115    pub fn closed_directions(&self) -> Result<(bool, bool), String> {
116        if let Some(&cached) = self.closed_directions.get() {
117            return Ok(cached);
118        }
119        const CLOSED_SEAM_TOLERANCE: f64 = 1e-6;
120        let [u0, u1] = self.domain_u()?;
121        let [v0, v1] = self.domain_v()?;
122        let closed_along = |direction_u: bool| -> Result<bool, String> {
123            // A direction whose two boundary rows coincide is closed only if
124            // the surface TRAVELS between them: a sliver patch narrower than
125            // the seam tolerance (a 7.24e-7-wide plane our own STL→STEP writer
126            // emits for a degenerate facet, 2026-09-26) has coincident rows
127            // too, and reading it as periodic wraps its far edge onto its near
128            // one — `evaluate_extended` then images a trim on that edge a
129            // whole patch width away.
130            let mut travels = false;
131            for fraction in [0.17, 0.5, 0.83] {
132                let (a, b, middle) = if direction_u {
133                    let v = v0 + (v1 - v0) * fraction;
134                    (
135                        self.evaluate(u0, v)?,
136                        self.evaluate(u1, v)?,
137                        self.evaluate(0.5 * (u0 + u1), v)?,
138                    )
139                } else {
140                    let u = u0 + (u1 - u0) * fraction;
141                    (
142                        self.evaluate(u, v0)?,
143                        self.evaluate(u, v1)?,
144                        self.evaluate(u, 0.5 * (v0 + v1))?,
145                    )
146                };
147                if a.sub(b).length() > CLOSED_SEAM_TOLERANCE {
148                    return Ok(false);
149                }
150                if middle.sub(a).length() > CLOSED_SEAM_TOLERANCE {
151                    travels = true;
152                }
153            }
154            Ok(travels)
155        };
156        let value = (closed_along(true)?, closed_along(false)?);
157        Ok(*self.closed_directions.get_or_init(|| value))
158    }
159
160    /// The full construction-time checks, run at most once per instance.
161    fn ensure_valid(&self) -> Result<(), String> {
162        if self.validated.get() {
163            return Ok(());
164        }
165        validate_knots(&self.knots_u, self.degree_u)?;
166        validate_knots(&self.knots_v, self.degree_v)?;
167        let rows = self.knots_u.len() - self.degree_u - 1;
168        let columns = self.knots_v.len() - self.degree_v - 1;
169        if self.control_points.len() != rows {
170            return Err(format!(
171                "NurbsSurface: u knot vector implies {} rows, got {}",
172                rows,
173                self.control_points.len()
174            ));
175        }
176        for row in &self.control_points {
177            if row.len() != columns {
178                return Err(format!(
179                    "NurbsSurface: v knot vector implies {} columns, got {}",
180                    columns,
181                    row.len()
182                ));
183            }
184            if row.iter().any(|point| {
185                point.w <= EPS
186                    || ![point.x, point.y, point.z, point.w]
187                        .iter()
188                        .all(|value| value.is_finite())
189            }) {
190                return Err(
191                    "NurbsSurface: control points must be finite with positive weights".into(),
192                );
193            }
194        }
195        self.validated.set(true);
196        Ok(())
197    }
198
199    pub fn domain_u(&self) -> Result<[f64; 2], String> {
200        self.ensure_valid()?;
201        Ok(knot_domain(&self.knots_u, self.degree_u))
202    }
203
204    pub fn domain_v(&self) -> Result<[f64; 2], String> {
205        self.ensure_valid()?;
206        Ok(knot_domain(&self.knots_v, self.degree_v))
207    }
208
209    /// The recognized analytic carrier, if this exact rational patch is a
210    /// plane or a full revolution quadric/torus. Computed once per instance.
211    pub fn analytic(&self) -> Option<&crate::AnalyticSurface> {
212        self.analytic
213            .get_or_init(|| crate::analytic_surface::recognize(self))
214            .as_ref()
215    }
216
217    /// The recognized affine sphere (an ellipsoid), under `BREP_AFFINE_SPHERE`
218    /// and only when this net is not an analytic carrier; `None` otherwise.
219    pub(crate) fn affine_sphere(&self) -> Option<&crate::affine_sphere::AffineSphere> {
220        self.affine_sphere
221            .get_or_init(|| {
222                if !crate::affine_sphere::affine_sphere_enabled() || self.analytic().is_some() {
223                    None
224                } else {
225                    crate::affine_sphere::recognize(self)
226                }
227            })
228            .as_ref()
229    }
230
231    /// The names of this surface's caches that hold a value, in declaration
232    /// order. For the boolean's operand-state instrument
233    /// (`BREP_DEBUG_OPERAND_STATE`), which reads which caches arrive warm.
234    pub(crate) fn warm_caches(&self) -> Vec<&'static str> {
235        let mut warm = Vec::new();
236        let cells = [
237            ("analytic", self.analytic.get().is_some()),
238            ("affine_sphere", self.affine_sphere.get().is_some()),
239            ("validated", self.validated.get()),
240            ("closed", self.closed_directions.get().is_some()),
241            ("grid", self.projection_grid.get().is_some()),
242            ("dense", self.projection_dense_grid.get().is_some()),
243            ("ring", self.projection_ring_grid.get().is_some()),
244            ("cells", self.projection_cells.get().is_some()),
245        ];
246        for (name, held) in cells {
247            if held {
248                warm.push(name);
249            }
250        }
251        warm
252    }
253
254    /// Empties the named caches (`warm_caches`' names; `all` for every one),
255    /// or, with `from`, sets each to `from`'s value. For the operand-state
256    /// instrument only: a cache that decides an answer is a defect, and this
257    /// is how the bisect finds it.
258    pub(crate) fn exchange_caches(&mut self, names: &[&str], from: Option<&Self>) {
259        let wants = |name: &str| names.iter().any(|&n| n == name || n == "all");
260        if wants("analytic") {
261            self.analytic = from.map(|f| f.analytic.clone()).unwrap_or_default();
262        }
263        if wants("affine_sphere") {
264            self.affine_sphere = from.map(|f| f.affine_sphere.clone()).unwrap_or_default();
265        }
266        if wants("validated") {
267            self.validated = std::cell::Cell::new(from.is_some_and(|f| f.validated.get()));
268        }
269        if wants("closed") {
270            self.closed_directions = from.map(|f| f.closed_directions.clone()).unwrap_or_default();
271        }
272        if wants("grid") {
273            self.projection_grid = from.map(|f| f.projection_grid.clone()).unwrap_or_default();
274        }
275        if wants("dense") {
276            self.projection_dense_grid = from.map(|f| f.projection_dense_grid.clone()).unwrap_or_default();
277        }
278        if wants("ring") {
279            self.projection_ring_grid = from.map(|f| f.projection_ring_grid.clone()).unwrap_or_default();
280        }
281        if wants("cells") {
282            self.projection_cells = from.map(|f| f.projection_cells.clone()).unwrap_or_default();
283        }
284    }
285
286    fn knot_vectors(&self) -> Result<(KnotVector, KnotVector), String> {
287        Ok((
288            KnotVector::new(self.knots_u.clone(), self.degree_u)?,
289            KnotVector::new(self.knots_v.clone(), self.degree_v)?,
290        ))
291    }
292
293    pub fn evaluate_homogeneous(&self, u: f64, v: f64) -> Result<Vec4, String> {
294        crate::projection_profile::count(crate::projection_profile::LANE_EVALUATE, self);
295        self.ensure_valid()?;
296        if self.degree_u > MAX_STACK_DEGREE || self.degree_v > MAX_STACK_DEGREE {
297            return self.evaluate_homogeneous_heap(u, v);
298        }
299        let u = knot_clamp(&self.knots_u, self.degree_u, u);
300        let v = knot_clamp(&self.knots_v, self.degree_v, v);
301        let span_u = knot_find_span(&self.knots_u, self.degree_u, u);
302        let span_v = knot_find_span(&self.knots_v, self.degree_v, v);
303        // Recognition evaluates points itself. Inspect only an initialized cache:
304        // asking analytic() here would recursively initialize that same cache.
305        if analytic_point_on() && matches!((self.degree_u, self.degree_v), (1, 1) | (2, 1) | (1, 2) | (2, 2))
306            && matches!(self.analytic.get().and_then(|carrier| carrier.as_ref()),
307                Some(crate::AnalyticSurface::Plane { .. } | crate::AnalyticSurface::RuledRevolution { .. }
308                    | crate::AnalyticSurface::Sphere { .. } | crate::AnalyticSurface::Torus { .. })) {
309            return Ok(match (self.degree_u, self.degree_v) {
310                (1, 1) => self.analytic_point_small::<1, 1>(u, v, span_u, span_v),
311                (2, 1) => self.analytic_point_small::<2, 1>(u, v, span_u, span_v),
312                (1, 2) => self.analytic_point_small::<1, 2>(u, v, span_u, span_v),
313                (2, 2) => self.analytic_point_small::<2, 2>(u, v, span_u, span_v),
314                _ => unreachable!(),
315            });
316        }
317        let mut basis_u = [0.0f64; MAX_STACK_ORDER];
318        let mut basis_v = [0.0f64; MAX_STACK_ORDER];
319        basis_functions_into(&self.knots_u, self.degree_u, span_u, u, &mut basis_u);
320        basis_functions_into(&self.knots_v, self.degree_v, span_v, v, &mut basis_v);
321        let mut point = Vec4 {
322            x: 0.0,
323            y: 0.0,
324            z: 0.0,
325            w: 0.0,
326        };
327        for (i, value_u) in basis_u[..=self.degree_u].iter().enumerate() {
328            let row = &self.control_points[span_u - self.degree_u + i];
329            for (j, value_v) in basis_v[..=self.degree_v].iter().enumerate() {
330                point = point.add(row[span_v - self.degree_v + j].scale(value_u * value_v));
331            }
332        }
333        Ok(point)
334    }
335
336    /// The stored rational net, with the general evaluator's arithmetic order.
337    /// Const degrees shrink the scratch arrays and specialize the short loops;
338    /// this is not a trigonometric replacement of the rational parameterization.
339    fn analytic_point_small<const DU: usize, const DV: usize>(&self, u: f64, v: f64, span_u: usize, span_v: usize) -> Vec4 {
340        fn basis<const D: usize>(knots: &[f64], span: usize, parameter: f64) -> [f64; 3] {
341            let mut values = [0.0; 3];
342            let mut left = [0.0; 3];
343            let mut right = [0.0; 3];
344            values[0] = 1.0;
345            for j in 1..=D {
346                left[j] = parameter - knots[span + 1 - j];
347                right[j] = knots[span + j] - parameter;
348                let mut saved = 0.0;
349                for r in 0..j {
350                    let denominator = right[r + 1] + left[j - r];
351                    let temporary = if denominator.abs() <= EPS { 0.0 } else { values[r] / denominator };
352                    values[r] = saved + right[r + 1] * temporary;
353                    saved = left[j - r] * temporary;
354                }
355                values[j] = saved;
356            }
357            values
358        }
359        let basis_u = basis::<DU>(&self.knots_u, span_u, u);
360        let basis_v = basis::<DV>(&self.knots_v, span_v, v);
361        let mut point = Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
362        for (i, value_u) in basis_u[..=DU].iter().enumerate() {
363            let row = &self.control_points[span_u - DU + i];
364            for (j, value_v) in basis_v[..=DV].iter().enumerate() {
365                point = point.add(row[span_v - DV + j].scale(value_u * value_v));
366            }
367        }
368        point
369    }
370
371    fn evaluate_homogeneous_heap(&self, u: f64, v: f64) -> Result<Vec4, String> {
372        let (knot_u, knot_v) = self.knot_vectors()?;
373        let u = knot_u.clamp_param(u);
374        let v = knot_v.clamp_param(v);
375        let span_u = knot_u.find_span(u);
376        let span_v = knot_v.find_span(v);
377        let basis_u = knot_u.basis_functions(span_u, u);
378        let basis_v = knot_v.basis_functions(span_v, v);
379        let mut point = Vec4 {
380            x: 0.0,
381            y: 0.0,
382            z: 0.0,
383            w: 0.0,
384        };
385        for (i, value_u) in basis_u.iter().enumerate() {
386            let row = &self.control_points[span_u - self.degree_u + i];
387            for (j, value_v) in basis_v.iter().enumerate() {
388                point = point.add(row[span_v - self.degree_v + j].scale(value_u * value_v));
389            }
390        }
391        Ok(point)
392    }
393
394    pub fn evaluate(&self, u: f64, v: f64) -> Result<Vec3, String> {
395        self.evaluate_homogeneous(u, v)?.point()
396    }
397
398    /// Value beyond the domain (Golovanov §3.15): closed directions wrap
399    /// cyclically; open directions extend along the boundary tangent
400    /// plane, with the bilinear corner form when both parameters are
401    /// outside.  Intersection and projection algorithms probe outside the
402    /// domain, so this is a required capability of every surface.
403    pub fn evaluate_extended(&self, u: f64, v: f64) -> Result<Vec3, String> {
404        if small_derivatives_enabled() {
405            return Ok(self.derivatives_extended_small(u, v, 0)?[0][0]);
406        }
407        Ok(self.derivatives_extended(u, v, 0)?[0][0])
408    }
409
410    /// Allocation-free twin of [`Self::derivatives_extended`] for
411    /// `derivative_count <= 2` (lane O row 20): the same wrap, anchor and
412    /// bilinear extension, over [`Self::derivatives_small`], which is the
413    /// byte-faithful stack copy of [`Self::derivatives`]. Entries outside
414    /// `k + l <= derivative_count` stay `Vec3::default()`, as the Vec form
415    /// leaves them.
416    pub(crate) fn derivatives_extended_small(
417        &self,
418        u: f64,
419        v: f64,
420        derivative_count: usize,
421    ) -> Result<[[Vec3; 3]; 3], String> {
422        debug_assert!(derivative_count <= 2);
423        let [u0, u1] = self.domain_u()?;
424        let [v0, v1] = self.domain_v()?;
425        let (closed_u, closed_v) = self.closed_directions()?;
426        let mut uu = u;
427        let mut vv = v;
428        if closed_u && (u < u0 || u > u1) {
429            uu = u0 + (u - u0).rem_euclid(u1 - u0);
430        }
431        if closed_v && (v < v0 || v > v1) {
432            vv = v0 + (v - v0).rem_euclid(v1 - v0);
433        }
434        let du_out = if uu < u0 { uu - u0 } else if uu > u1 { uu - u1 } else { 0.0 };
435        let dv_out = if vv < v0 { vv - v0 } else if vv > v1 { vv - v1 } else { 0.0 };
436        if du_out == 0.0 && dv_out == 0.0 {
437            return self.derivatives_small(uu, vv, derivative_count);
438        }
439        let anchor_u = uu - du_out;
440        let anchor_v = vv - dv_out;
441        let base = self.derivatives_small(anchor_u, anchor_v, 2)?;
442        let mut result = [[Vec3::default(); 3]; 3];
443        result[0][0] = base[0][0]
444            .add(base[1][0].scale(du_out))
445            .add(base[0][1].scale(dv_out))
446            .add(base[1][1].scale(du_out * dv_out));
447        if derivative_count >= 1 {
448            result[1][0] = base[1][0].add(base[1][1].scale(dv_out));
449            result[0][1] = base[0][1].add(base[1][1].scale(du_out));
450            result[1][1] = base[1][1];
451        }
452        if derivative_count >= 2 {
453            if du_out == 0.0 {
454                result[2][0] = base[2][0];
455            }
456            if dv_out == 0.0 {
457                result[0][2] = base[0][2];
458            }
459        }
460        Ok(result)
461    }
462
463    /// Derivatives beyond the domain (§3.15).  The extension is C¹: first
464    /// derivatives follow the ruled/bilinear extension, second derivatives
465    /// are taken at the boundary anchor (zero across an extended
466    /// direction).
467    pub fn derivatives_extended(
468        &self,
469        u: f64,
470        v: f64,
471        derivative_count: usize,
472    ) -> Result<Vec<Vec<Vec3>>, String> {
473        if derivative_count <= 2 && small_derivatives_enabled() {
474            let small = self.derivatives_extended_small(u, v, derivative_count)?;
475            let order = derivative_count + 1;
476            return Ok((0..order).map(|k| (0..order).map(|l| small[k][l]).collect()).collect());
477        }
478        let [u0, u1] = self.domain_u()?;
479        let [v0, v1] = self.domain_v()?;
480        let (closed_u, closed_v) = self.closed_directions()?;
481        let mut uu = u;
482        let mut vv = v;
483        if closed_u && (u < u0 || u > u1) {
484            uu = u0 + (u - u0).rem_euclid(u1 - u0);
485        }
486        if closed_v && (v < v0 || v > v1) {
487            vv = v0 + (v - v0).rem_euclid(v1 - v0);
488        }
489        let du_out = if uu < u0 {
490            uu - u0
491        } else if uu > u1 {
492            uu - u1
493        } else {
494            0.0
495        };
496        let dv_out = if vv < v0 {
497            vv - v0
498        } else if vv > v1 {
499            vv - v1
500        } else {
501            0.0
502        };
503        if du_out == 0.0 && dv_out == 0.0 {
504            return self.derivatives(uu, vv, derivative_count);
505        }
506        let anchor_u = uu - du_out;
507        let anchor_v = vv - dv_out;
508        let base = self.derivatives(anchor_u, anchor_v, derivative_count.max(2))?;
509        let order = derivative_count + 1;
510        let mut result = vec![vec![Vec3::default(); order]; order];
511        // Bilinear extension: S = A00 + du·A10 + dv·A01 + du·dv·A11
512        // (reduces to the ruled tangent-plane extension when only one
513        // parameter is outside).
514        result[0][0] = base[0][0]
515            .add(base[1][0].scale(du_out))
516            .add(base[0][1].scale(dv_out))
517            .add(base[1][1].scale(du_out * dv_out));
518        if derivative_count >= 1 {
519            result[1][0] = base[1][0].add(base[1][1].scale(dv_out));
520            result[0][1] = base[0][1].add(base[1][1].scale(du_out));
521            result[1][1] = base[1][1];
522        }
523        if derivative_count >= 2 {
524            let second = self.derivatives(anchor_u, anchor_v, 2)?;
525            if du_out == 0.0 {
526                result[2][0] = second[2][0];
527            }
528            if dv_out == 0.0 {
529                result[0][2] = second[0][2];
530            }
531        }
532        Ok(result)
533    }
534
535    pub fn derivatives(
536        &self,
537        u: f64,
538        v: f64,
539        derivative_count: usize,
540    ) -> Result<Vec<Vec<Vec3>>, String> {
541        crate::projection_profile::count(crate::projection_profile::LANE_DERIVATIVES, self);
542        self.ensure_valid()?;
543        let du = derivative_count.min(self.degree_u);
544        let dv = derivative_count.min(self.degree_v);
545        let stride = derivative_count + 1;
546        // Flat (k, l) grid: one allocation instead of nested per-row Vecs.
547        let zero = Vec4 {
548            x: 0.0,
549            y: 0.0,
550            z: 0.0,
551            w: 0.0,
552        };
553        let mut homogeneous = vec![zero; stride * stride];
554        if self.degree_u <= MAX_STACK_DEGREE && self.degree_v <= MAX_STACK_DEGREE {
555            let u = knot_clamp(&self.knots_u, self.degree_u, u);
556            let v = knot_clamp(&self.knots_v, self.degree_v, v);
557            let span_u = knot_find_span(&self.knots_u, self.degree_u, u);
558            let span_v = knot_find_span(&self.knots_v, self.degree_v, v);
559            // `derivative_count` reaches the degree here (blends ask for third
560            // partials), so the rows stay eight wide.
561            let mut basis_u = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
562            let mut basis_v = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
563            basis_derivatives_into(
564                &self.knots_u,
565                self.degree_u,
566                span_u,
567                u,
568                du,
569                &mut basis_u[..=du],
570            );
571            basis_derivatives_into(
572                &self.knots_v,
573                self.degree_v,
574                span_v,
575                v,
576                dv,
577                &mut basis_v[..=dv],
578            );
579            for k in 0..=du {
580                for l in 0..=dv {
581                    if k + l > derivative_count {
582                        continue;
583                    }
584                    let mut point = zero;
585                    for i in 0..=self.degree_u {
586                        let row = &self.control_points[span_u - self.degree_u + i];
587                        for j in 0..=self.degree_v {
588                            point = point.add(
589                                row[span_v - self.degree_v + j]
590                                    .scale(basis_u[k][i] * basis_v[l][j]),
591                            );
592                        }
593                    }
594                    homogeneous[k * stride + l] = point;
595                }
596            }
597        } else {
598            let (knot_u, knot_v) = self.knot_vectors()?;
599            let u = knot_u.clamp_param(u);
600            let v = knot_v.clamp_param(v);
601            let span_u = knot_u.find_span(u);
602            let span_v = knot_v.find_span(v);
603            let basis_u = knot_u.basis_derivatives(span_u, u, du);
604            let basis_v = knot_v.basis_derivatives(span_v, v, dv);
605            for k in 0..=du {
606                for l in 0..=dv {
607                    if k + l > derivative_count {
608                        continue;
609                    }
610                    let mut point = zero;
611                    for i in 0..=self.degree_u {
612                        let row = &self.control_points[span_u - self.degree_u + i];
613                        for j in 0..=self.degree_v {
614                            point = point.add(
615                                row[span_v - self.degree_v + j]
616                                    .scale(basis_u[k][i] * basis_v[l][j]),
617                            );
618                        }
619                    }
620                    homogeneous[k * stride + l] = point;
621                }
622            }
623        }
624
625        let mut result = vec![vec![Vec3::default(); derivative_count + 1]; derivative_count + 1];
626        let weight = homogeneous[0].w;
627        if weight.abs() <= EPS {
628            return Err("NurbsSurface: zero evaluated weight".into());
629        }
630        for k in 0..=derivative_count {
631            for l in 0..=derivative_count - k {
632                if k > du || l > dv {
633                    continue;
634                }
635                let at = |row: usize, column: usize| homogeneous[row * stride + column];
636                let mut value = Vec3::new(at(k, l).x, at(k, l).y, at(k, l).z);
637                for j in 1..=l {
638                    value = value.sub(result[k][l - j].scale(binomial(l, j) * at(0, j).w));
639                }
640                for i in 1..=k {
641                    value = value.sub(result[k - i][l].scale(binomial(k, i) * at(i, 0).w));
642                    let mut mixed = Vec3::default();
643                    for j in 1..=l {
644                        mixed = mixed.add(result[k - i][l - j].scale(binomial(l, j) * at(i, j).w));
645                    }
646                    value = value.sub(mixed.scale(binomial(k, i)));
647                }
648                result[k][l] = value.scale(1.0 / weight);
649            }
650        }
651        Ok(result)
652    }
653
654    /// Allocation-free twin of [`Self::derivatives`] for the hot
655    /// `derivative_count <= 2` path (point, first, and second partials).
656    ///
657    /// This is a byte-for-byte faithful copy of the arithmetic in
658    /// [`Self::derivatives`]: identical basis evaluation, identical summation
659    /// order (control-point row `i` outer, column `j` inner, `scale`-then-`add`),
660    /// and the identical rational `A(u,v)/w(u,v)` de-homogenization recurrence.
661    /// Only the storage differs — fixed-size stack arrays (`[Vec4; 9]` for the
662    /// `(count+1)²` homogeneous grid, `[[Vec3; 3]; 3]` for the result) replace
663    /// the heap `Vec<Vec4>` / `Vec<Vec<Vec3>>`. Grid entries outside
664    /// `k + l <= derivative_count` (and outside `k <= du`, `l <= dv`) stay
665    /// `Vec3::default()`, exactly as the heap version leaves them, so callers may
666    /// index `[k][l]` for any `k + l <= 2` and read the same value the Vec API
667    /// would have produced.
668    pub(crate) fn derivatives_small(
669        &self,
670        u: f64,
671        v: f64,
672        derivative_count: usize,
673    ) -> Result<[[Vec3; 3]; 3], String> {
674        debug_assert!(derivative_count <= 2);
675        self.ensure_valid()?;
676        let du = derivative_count.min(self.degree_u);
677        let dv = derivative_count.min(self.degree_v);
678        let stride = derivative_count + 1;
679        let zero = Vec4 {
680            x: 0.0,
681            y: 0.0,
682            z: 0.0,
683            w: 0.0,
684        };
685        let mut homogeneous = [zero; 9];
686        if self.degree_u <= MAX_STACK_DEGREE && self.degree_v <= MAX_STACK_DEGREE {
687            let u = knot_clamp(&self.knots_u, self.degree_u, u);
688            let v = knot_clamp(&self.knots_v, self.degree_v, v);
689            let span_u = knot_find_span(&self.knots_u, self.degree_u, u);
690            let span_v = knot_find_span(&self.knots_v, self.degree_v, v);
691            // Rows `..=du` / `..=dv` (at most three) are the only ones written
692            // or read; the eight-row fill was a measurable share of a sign
693            // reading's surface station (2026-11-02).
694            let mut basis_u = [[0.0f64; MAX_STACK_ORDER]; 3];
695            let mut basis_v = [[0.0f64; MAX_STACK_ORDER]; 3];
696            basis_derivatives_into(
697                &self.knots_u,
698                self.degree_u,
699                span_u,
700                u,
701                du,
702                &mut basis_u[..=du],
703            );
704            basis_derivatives_into(
705                &self.knots_v,
706                self.degree_v,
707                span_v,
708                v,
709                dv,
710                &mut basis_v[..=dv],
711            );
712            for k in 0..=du {
713                for l in 0..=dv {
714                    if k + l > derivative_count {
715                        continue;
716                    }
717                    let mut point = zero;
718                    for i in 0..=self.degree_u {
719                        let row = &self.control_points[span_u - self.degree_u + i];
720                        for j in 0..=self.degree_v {
721                            point = point.add(
722                                row[span_v - self.degree_v + j]
723                                    .scale(basis_u[k][i] * basis_v[l][j]),
724                            );
725                        }
726                    }
727                    homogeneous[k * stride + l] = point;
728                }
729            }
730        } else {
731            let (knot_u, knot_v) = self.knot_vectors()?;
732            let u = knot_u.clamp_param(u);
733            let v = knot_v.clamp_param(v);
734            let span_u = knot_u.find_span(u);
735            let span_v = knot_v.find_span(v);
736            let basis_u = knot_u.basis_derivatives(span_u, u, du);
737            let basis_v = knot_v.basis_derivatives(span_v, v, dv);
738            for k in 0..=du {
739                for l in 0..=dv {
740                    if k + l > derivative_count {
741                        continue;
742                    }
743                    let mut point = zero;
744                    for i in 0..=self.degree_u {
745                        let row = &self.control_points[span_u - self.degree_u + i];
746                        for j in 0..=self.degree_v {
747                            point = point.add(
748                                row[span_v - self.degree_v + j]
749                                    .scale(basis_u[k][i] * basis_v[l][j]),
750                            );
751                        }
752                    }
753                    homogeneous[k * stride + l] = point;
754                }
755            }
756        }
757
758        let mut result = [[Vec3::default(); 3]; 3];
759        let weight = homogeneous[0].w;
760        if weight.abs() <= EPS {
761            return Err("NurbsSurface: zero evaluated weight".into());
762        }
763        for k in 0..=derivative_count {
764            for l in 0..=derivative_count - k {
765                if k > du || l > dv {
766                    continue;
767                }
768                let at = |row: usize, column: usize| homogeneous[row * stride + column];
769                let mut value = Vec3::new(at(k, l).x, at(k, l).y, at(k, l).z);
770                for j in 1..=l {
771                    value = value.sub(result[k][l - j].scale(binomial(l, j) * at(0, j).w));
772                }
773                for i in 1..=k {
774                    value = value.sub(result[k - i][l].scale(binomial(k, i) * at(i, 0).w));
775                    let mut mixed = Vec3::default();
776                    for j in 1..=l {
777                        mixed = mixed.add(result[k - i][l - j].scale(binomial(l, j) * at(i, j).w));
778                    }
779                    value = value.sub(mixed.scale(binomial(k, i)));
780                }
781                result[k][l] = value.scale(1.0 / weight);
782            }
783        }
784        Ok(result)
785    }
786
787    /// Whether [`Self::deriv1_tensor_each`] can drive this patch: the stack
788    /// basis tables only reach `MAX_STACK_DEGREE`, and a higher-degree patch
789    /// must be evaluated station by station through [`Self::deriv1`].
790    #[inline]
791    pub(crate) fn deriv1_tensor_supported(&self) -> bool {
792        self.degree_u <= MAX_STACK_DEGREE && self.degree_v <= MAX_STACK_DEGREE
793    }
794
795    /// `(S, S_u, S_v)` at every station of the TENSOR BLOCK `us x vs`, handed
796    /// to `f` as `(u index, v index, S, S_u, S_v)` with `us` outer and `vs`
797    /// inner.
798    ///
799    /// Bit-identical to calling [`Self::deriv1`] at each pair, and that is the
800    /// whole point of it: the block's `us.len() + vs.len()` spans and basis
801    /// rows are evaluated ONCE each instead of once per station, which is
802    /// where a fixed tensor-Gauss cell spends most of its time (the basis
803    /// recurrence carries a division per knot per derivative order, and an
804    /// 8x8 cell asks for the same 8 u rows and 8 v rows sixty-four times).
805    /// Nothing else changes: `knot_clamp` / `knot_find_span` /
806    /// `basis_derivatives_into` see the same arguments and so return the same
807    /// floats, the control-point sum runs in the same row-outer/column-inner
808    /// order from the same zero `Vec4`, and the rational de-homogenization is
809    /// [`Self::derivatives_small`]'s recurrence written out for
810    /// `derivative_count == 1`.
811    ///
812    /// Two steps of that recurrence are elided because they are exact no-ops
813    /// at this order: `binomial(1, 1)` is exactly `1.0`, and the mixed term it
814    /// scales is the zero vector, whose subtraction leaves every component
815    /// (including a negative zero) bit-unchanged.
816    pub(crate) fn deriv1_tensor_each<F>(
817        &self,
818        us: &[f64],
819        vs: &[f64],
820        mut f: F,
821    ) -> Result<(), String>
822    where
823        F: FnMut(usize, usize, Vec3, Vec3, Vec3) -> Result<(), String>,
824    {
825        self.ensure_valid()?;
826        // Width and rationale: `TENSOR_MAX_BLOCK` at the top of this module.
827        const MAX_BLOCK: usize = TENSOR_MAX_BLOCK;
828        if !self.deriv1_tensor_supported() || us.len() > MAX_BLOCK || vs.len() > MAX_BLOCK {
829            return Err("NurbsSurface: tensor block outside the stack evaluator".into());
830        }
831        let du = 1usize.min(self.degree_u);
832        let dv = 1usize.min(self.degree_v);
833        let mut span_u = [0usize; MAX_BLOCK];
834        let mut span_v = [0usize; MAX_BLOCK];
835        let mut basis_u = [[[0.0f64; MAX_STACK_ORDER]; 2]; MAX_BLOCK];
836        let mut basis_v = [[[0.0f64; MAX_STACK_ORDER]; 2]; MAX_BLOCK];
837        for (index, &value) in us.iter().enumerate() {
838            let value = knot_clamp(&self.knots_u, self.degree_u, value);
839            span_u[index] = knot_find_span(&self.knots_u, self.degree_u, value);
840            basis_derivatives_into(
841                &self.knots_u,
842                self.degree_u,
843                span_u[index],
844                value,
845                du,
846                &mut basis_u[index][..=du],
847            );
848        }
849        for (index, &value) in vs.iter().enumerate() {
850            let value = knot_clamp(&self.knots_v, self.degree_v, value);
851            span_v[index] = knot_find_span(&self.knots_v, self.degree_v, value);
852            basis_derivatives_into(
853                &self.knots_v,
854                self.degree_v,
855                span_v[index],
856                value,
857                dv,
858                &mut basis_v[index][..=dv],
859            );
860        }
861        let zero = Vec4 {
862            x: 0.0,
863            y: 0.0,
864            z: 0.0,
865            w: 0.0,
866        };
867        for i in 0..us.len() {
868            let rows = &self.control_points[span_u[i] - self.degree_u..];
869            for j in 0..vs.len() {
870                let column = span_v[j] - self.degree_v;
871                // The (k, l) homogeneous grid `derivatives_small` builds for
872                // `derivative_count == 1`: (0,0) always, (0,1) when dv >= 1,
873                // (1,0) when du >= 1.
874                let sum = |k: usize, l: usize| {
875                    let mut point = zero;
876                    for row_index in 0..=self.degree_u {
877                        let row = &rows[row_index];
878                        for column_index in 0..=self.degree_v {
879                            point = point.add(
880                                row[column + column_index]
881                                    .scale(basis_u[i][k][row_index] * basis_v[j][l][column_index]),
882                            );
883                        }
884                    }
885                    point
886                };
887                let h00 = sum(0, 0);
888                let h01 = if dv >= 1 { sum(0, 1) } else { zero };
889                let h10 = if du >= 1 { sum(1, 0) } else { zero };
890                let weight = h00.w;
891                if weight.abs() <= EPS {
892                    return Err("NurbsSurface: zero evaluated weight".into());
893                }
894                let inverse = 1.0 / weight;
895                let point = Vec3::new(h00.x, h00.y, h00.z).scale(inverse);
896                let partial_v = if dv >= 1 {
897                    Vec3::new(h01.x, h01.y, h01.z)
898                        .sub(point.scale(h01.w))
899                        .scale(inverse)
900                } else {
901                    Vec3::default()
902                };
903                let partial_u = if du >= 1 {
904                    Vec3::new(h10.x, h10.y, h10.z)
905                        .sub(point.scale(h10.w))
906                        .scale(inverse)
907                } else {
908                    Vec3::default()
909                };
910                f(i, j, point, partial_u, partial_v)?;
911            }
912        }
913        Ok(())
914    }
915
916    /// Point and first partials `(S, S_u, S_v)` with zero heap allocation.
917    /// Bit-identical to `derivatives(u, v, 1)` at `[0][0] / [1][0] / [0][1]`.
918    #[inline]
919    pub(crate) fn deriv1(&self, u: f64, v: f64) -> Result<(Vec3, Vec3, Vec3), String> {
920        // Degrees 1 and 2 in both directions (every carrier the kernel builds
921        // and the fitted patches it blends with) take the first-order twin
922        // with both degrees fixed at compile time; its arithmetic is
923        // `derivatives_small`'s to the bit (`deriv1_small_fixed`), and the
924        // mass sign reading's chord stations were a third `derivatives_small`
925        // self time after the basis recurrence (2026-11-02).
926        match (self.degree_u, self.degree_v) {
927            (1, 1) => self.deriv1_small_fixed::<1, 1>(u, v),
928            (2, 1) => self.deriv1_small_fixed::<2, 1>(u, v),
929            (1, 2) => self.deriv1_small_fixed::<1, 2>(u, v),
930            (2, 2) => self.deriv1_small_fixed::<2, 2>(u, v),
931            _ => {
932                let g = self.derivatives_small(u, v, 1)?;
933                Ok((g[0][0], g[1][0], g[0][1]))
934            }
935        }
936    }
937
938    /// First-order rational recurrence with only its three live sums, for a
939    /// patch whose degrees are the const parameters. The clamp, span, basis
940    /// rows, control summation (row outer, column inner, scale then add from
941    /// the zero `Vec4`) and dehomogenization are `derivatives_small`'s in the
942    /// same order; the two recurrence steps written out here that
943    /// `derivatives_small` runs generically are exact no-ops at this order
944    /// (`binomial(1, 1)` is exactly `1.0`; the mixed term is the zero vector,
945    /// whose subtraction changes no bit). So the three results are the same
946    /// bits as `derivatives_small(u, v, 1)` at `[0][0]`, `[1][0]`, `[0][1]`
947    /// for ANY surface of these degrees, analytic or not
948    /// (`deriv1_degree_dispatch_tests`). Caller retains the existing
949    /// wrapping, extension and refusal paths.
950    fn deriv1_small_fixed<const DEGREE_U: usize, const DEGREE_V: usize>(&self, u: f64, v: f64) -> Result<(Vec3, Vec3, Vec3), String> {
951        self.ensure_valid()?;
952        let du = DEGREE_U.min(1);
953        let dv = DEGREE_V.min(1);
954        let u = knot_clamp(&self.knots_u, DEGREE_U, u);
955        let v = knot_clamp(&self.knots_v, DEGREE_V, v);
956        let span_u = knot_find_span(&self.knots_u, DEGREE_U, u);
957        let span_v = knot_find_span(&self.knots_v, DEGREE_V, v);
958        let mut basis_u = [[0.0; MAX_STACK_ORDER]; 2];
959        let mut basis_v = [[0.0; MAX_STACK_ORDER]; 2];
960        basis_derivatives_into(&self.knots_u, DEGREE_U, span_u, u, du, &mut basis_u[..=du]);
961        basis_derivatives_into(&self.knots_v, DEGREE_V, span_v, v, dv, &mut basis_v[..=dv]);
962        let zero = Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
963        let sum = |k: usize, l: usize| {
964            let mut value = zero;
965            for i in 0..=DEGREE_U {
966                let row = &self.control_points[span_u - DEGREE_U + i];
967                for j in 0..=DEGREE_V {
968                    value = value.add(row[span_v - DEGREE_V + j].scale(basis_u[k][i] * basis_v[l][j]));
969                }
970            }
971            value
972        };
973        let h00 = sum(0, 0);
974        let h01 = if dv == 1 { sum(0, 1) } else { zero };
975        let h10 = if du == 1 { sum(1, 0) } else { zero };
976        if h00.w.abs() <= EPS { return Err("NurbsSurface: zero evaluated weight".into()); }
977        let inverse = 1.0 / h00.w;
978        let point = Vec3::new(h00.x, h00.y, h00.z).scale(inverse);
979        let partial_v = if dv == 1 {
980            Vec3::new(h01.x, h01.y, h01.z).sub(point.scale(1.0 * h01.w)).scale(inverse)
981        } else { Vec3::default() };
982        let partial_u = if du == 1 {
983            Vec3::new(h10.x, h10.y, h10.z).sub(point.scale(1.0 * h10.w))
984                .sub(Vec3::default().scale(1.0)).scale(inverse)
985        } else { Vec3::default() };
986        Ok((point, partial_u, partial_v))
987    }
988
989    /// Allocation-free `(S, S_u, S_v)` twin of `derivatives_extended(u, v, 1)`.
990    ///
991    /// Mirrors the exact C¹ ruled/bilinear extension arithmetic of
992    /// [`Self::derivatives_extended`] (only the count == 1 partials), but drives
993    /// it from [`Self::derivatives_small`] so the in-domain and boundary-anchor
994    /// evaluations allocate nothing. Bit-identical to
995    /// `derivatives_extended(u, v, 1)` at `[0][0] / [1][0] / [0][1]`.
996    pub(crate) fn deriv1_extended(&self, u: f64, v: f64) -> Result<(Vec3, Vec3, Vec3), String> {
997        let [u0, u1] = self.domain_u()?;
998        let [v0, v1] = self.domain_v()?;
999        let (closed_u, closed_v) = self.closed_directions()?;
1000        let mut uu = u;
1001        let mut vv = v;
1002        if closed_u && (u < u0 || u > u1) {
1003            uu = u0 + (u - u0).rem_euclid(u1 - u0);
1004        }
1005        if closed_v && (v < v0 || v > v1) {
1006            vv = v0 + (v - v0).rem_euclid(v1 - v0);
1007        }
1008        let du_out = if uu < u0 {
1009            uu - u0
1010        } else if uu > u1 {
1011            uu - u1
1012        } else {
1013            0.0
1014        };
1015        let dv_out = if vv < v0 {
1016            vv - v0
1017        } else if vv > v1 {
1018            vv - v1
1019        } else {
1020            0.0
1021        };
1022        if du_out == 0.0 && dv_out == 0.0 {
1023            if analytic_deriv1_on() && matches!((self.degree_u, self.degree_v), (1, 1) | (2, 1) | (1, 2) | (2, 2))
1024                && matches!(self.analytic(), Some(crate::AnalyticSurface::Plane { .. }
1025                    | crate::AnalyticSurface::RuledRevolution { .. } | crate::AnalyticSurface::Sphere { .. }
1026                    | crate::AnalyticSurface::Torus { .. })) {
1027                return match (self.degree_u, self.degree_v) {
1028                    (1, 1) => self.deriv1_small_fixed::<1, 1>(uu, vv),
1029                    (2, 1) => self.deriv1_small_fixed::<2, 1>(uu, vv),
1030                    (1, 2) => self.deriv1_small_fixed::<1, 2>(uu, vv),
1031                    (2, 2) => self.deriv1_small_fixed::<2, 2>(uu, vv),
1032                    _ => unreachable!(),
1033                };
1034            }
1035            let g = self.derivatives_small(uu, vv, 1)?;
1036            return Ok((g[0][0], g[1][0], g[0][1]));
1037        }
1038        let anchor_u = uu - du_out;
1039        let anchor_v = vv - dv_out;
1040        let base = self.derivatives_small(anchor_u, anchor_v, 2)?;
1041        // Bilinear extension: S = A00 + du·A10 + dv·A01 + du·dv·A11.
1042        // The mixed anchor derivative has total order two, even when
1043        // the caller requests only the value or first partials.
1044        let s = base[0][0]
1045            .add(base[1][0].scale(du_out))
1046            .add(base[0][1].scale(dv_out))
1047            .add(base[1][1].scale(du_out * dv_out));
1048        let su = base[1][0].add(base[1][1].scale(dv_out));
1049        let sv = base[0][1].add(base[1][1].scale(du_out));
1050        Ok((s, su, sv))
1051    }
1052
1053    /// One first partial at `(u, v)` (clamped into the domain), `S_u` when
1054    /// `along_u`, else `S_v`, evaluated from the DIFFERENCES of the control net
1055    /// rather than from the basis derivatives.
1056    ///
1057    /// [`Self::derivatives`] forms `S_u = (A_u − W_u·S) / W` from `A_u = Σ N′ᵢ·Hᵢ`,
1058    /// a sum of terms the size of the control points whose exact total may be
1059    /// zero. Its rounding is therefore ABSOLUTE, a few ulps of the point, and
1060    /// where the partial itself vanishes (an isoline on which the
1061    /// parameterization is stationary, as on an extrusion of a cubic with
1062    /// coincident end poles) the DIRECTION of what is left is that rounding:
1063    /// measured 2.7e-15 of `x` on a partial of 7.7e-5 along `y`, a normal tilted
1064    /// by 3.5e-11.
1065    ///
1066    /// The hodograph `A_u = Σ N_{i+1,p−1}·p·(H_{i+1} − H_i)/(ξ_{i+p+1} − ξ_{i+1})`
1067    /// (The NURBS Book eq. 3.4, per row) carries the same partial with rounding
1068    /// that scales with its own terms: coincident control points difference to
1069    /// an exact zero, and every other difference is weighted by a basis function
1070    /// that vanishes with the partial. The rational part subtracts
1071    /// `W_u·S` with `W_u` taken from the weight differences, which is an exact
1072    /// zero wherever neighbouring weights are equal.
1073    ///
1074    /// Not bit-identical to `derivatives`, so it is read only where the bare
1075    /// partial has lost its direction (`offset/point.rs`). Refuses above the
1076    /// stack basis tables' degree.
1077    pub(crate) fn partial_by_differences(
1078        &self,
1079        along_u: bool,
1080        u: f64,
1081        v: f64,
1082    ) -> Result<Vec3, String> {
1083        self.ensure_valid()?;
1084        if self.degree_u > MAX_STACK_DEGREE || self.degree_v > MAX_STACK_DEGREE {
1085            return Err("NurbsSurface: partial by differences exceeds the stack degree".into());
1086        }
1087        let (pu, pv) = (self.degree_u, self.degree_v);
1088        let u = knot_clamp(&self.knots_u, pu, u);
1089        let v = knot_clamp(&self.knots_v, pv, v);
1090        let span_u = knot_find_span(&self.knots_u, pu, u);
1091        let span_v = knot_find_span(&self.knots_v, pv, v);
1092        let mut full_u = [0.0f64; MAX_STACK_ORDER];
1093        let mut full_v = [0.0f64; MAX_STACK_ORDER];
1094        basis_functions_into(&self.knots_u, pu, span_u, u, &mut full_u);
1095        basis_functions_into(&self.knots_v, pv, span_v, v, &mut full_v);
1096        let zero = Vec4 {
1097            x: 0.0,
1098            y: 0.0,
1099            z: 0.0,
1100            w: 0.0,
1101        };
1102        let control = |i: usize, j: usize| self.control_points[span_u - pu + i][span_v - pv + j];
1103        let mut point = zero;
1104        for i in 0..=pu {
1105            for j in 0..=pv {
1106                point = point.add(control(i, j).scale(full_u[i] * full_v[j]));
1107            }
1108        }
1109        if point.w.abs() <= EPS {
1110            return Err("NurbsSurface: zero evaluated weight".into());
1111        }
1112        let source = Vec3::new(point.x, point.y, point.z).scale(1.0 / point.w);
1113        let (knots, degree, span, parameter) = if along_u {
1114            (&self.knots_u, pu, span_u, u)
1115        } else {
1116            (&self.knots_v, pv, span_v, v)
1117        };
1118        if degree == 0 {
1119            return Ok(Vec3::default());
1120        }
1121        let mut lowered = [0.0f64; MAX_STACK_ORDER];
1122        basis_functions_into(knots, degree - 1, span, parameter, &mut lowered);
1123        // The hodograph's homogeneous value, Σ over the (degree) differences of
1124        // the basis span times the other direction's full basis.
1125        let mut hodograph = zero;
1126        for r in 0..degree {
1127            // Difference r joins control indices (span − degree + r) and
1128            // (span − degree + r + 1); its lowered basis is N_{span−degree+r+1, degree−1}.
1129            let index = span - degree + r;
1130            let width = knots[index + degree + 1] - knots[index + 1];
1131            if width.abs() <= EPS {
1132                continue;
1133            }
1134            let factor = lowered[r] * degree as f64 / width;
1135            if along_u {
1136                for j in 0..=pv {
1137                    let difference = control(r + 1, j).add(control(r, j).scale(-1.0));
1138                    hodograph = hodograph.add(difference.scale(factor * full_v[j]));
1139                }
1140            } else {
1141                for i in 0..=pu {
1142                    let difference = control(i, r + 1).add(control(i, r).scale(-1.0));
1143                    hodograph = hodograph.add(difference.scale(factor * full_u[i]));
1144                }
1145            }
1146        }
1147        Ok(Vec3::new(hodograph.x, hodograph.y, hodograph.z)
1148            .sub(source.scale(hodograph.w))
1149            .scale(1.0 / point.w))
1150    }
1151
1152    pub fn normal(&self, u: f64, v: f64) -> Result<Vec3, String> {
1153        if small_derivatives_enabled() {
1154            let derivatives = self.derivatives_small(u, v, 1)?;
1155            return derivatives[1][0].cross(derivatives[0][1]).normalized();
1156        }
1157        let derivatives = self.derivatives(u, v, 1)?;
1158        derivatives[1][0].cross(derivatives[0][1]).normalized()
1159    }
1160
1161    /// Principal curvatures (κ_min, κ_max) at (u, v), signed with respect to
1162    /// the parametrization normal n = Su × Sv / |Su × Sv| via the shape
1163    /// operator I⁻¹·II.  Convention check: a cylinder of radius R whose normal
1164    /// points AWAY from the axis has κ = −1/R along the circular direction, so
1165    /// an offset's regularity factor 1 − d·κ vanishes exactly when an inward
1166    /// offset (d = −R) reaches the axis.
1167    pub fn principal_curvatures(&self, u: f64, v: f64) -> Result<(f64, f64), String> {
1168        let derivatives: [[Vec3; 3]; 3] = if small_derivatives_enabled() {
1169            self.derivatives_small(u, v, 2)?
1170        } else {
1171            let heap = self.derivatives(u, v, 2)?;
1172            [[heap[0][0], heap[0][1], heap[0][2]], [heap[1][0], heap[1][1], Vec3::default()], [heap[2][0], Vec3::default(), Vec3::default()]]
1173        };
1174        let su = derivatives[1][0];
1175        let sv = derivatives[0][1];
1176        let cross = su.cross(sv);
1177        let cross_length = cross.length();
1178        if cross_length <= 1e-12 {
1179            return Err(format!(
1180                "degenerate parametrization at (u={u:.4}, v={v:.4})"
1181            ));
1182        }
1183        let normal = cross.scale(1.0 / cross_length);
1184        let e1 = su.dot(su);
1185        let f1 = su.dot(sv);
1186        let g1 = sv.dot(sv);
1187        let l2 = derivatives[2][0].dot(normal);
1188        let m2 = derivatives[1][1].dot(normal);
1189        let n2 = derivatives[0][2].dot(normal);
1190        let denominator = e1 * g1 - f1 * f1;
1191        let mean_double = (l2 * g1 - 2.0 * m2 * f1 + n2 * e1) / denominator; // 2H
1192        let gauss = (l2 * n2 - m2 * m2) / denominator; // K
1193        let discriminant = (mean_double * mean_double * 0.25 - gauss).max(0.0).sqrt();
1194        Ok((
1195            mean_double * 0.5 - discriminant,
1196            mean_double * 0.5 + discriminant,
1197        ))
1198    }
1199
1200    /// Whether this is a genuinely affine degree-1 by degree-1 patch.
1201    ///
1202    /// A tapered bilinear patch is planar but not affine, so degree alone is
1203    /// insufficient for exact inverse-parameter and integration shortcuts.
1204    pub fn is_affine(&self) -> Result<bool, String> {
1205        if self.degree_u != 1
1206            || self.degree_v != 1
1207            || self.control_points.len() != 2
1208            || self.control_points[0].len() != 2
1209            || self.control_points[1].len() != 2
1210        {
1211            return Ok(false);
1212        }
1213        let weight = self.control_points[0][0].w;
1214        if [
1215            self.control_points[0][1].w,
1216            self.control_points[1][0].w,
1217            self.control_points[1][1].w,
1218        ]
1219        .iter()
1220        .any(|other| (*other - weight).abs() > 1e-12 * other.abs().max(weight.abs()))
1221        {
1222            return Ok(false);
1223        }
1224        let p00 = self.control_points[0][0].point()?;
1225        let p01 = self.control_points[0][1].point()?;
1226        let p10 = self.control_points[1][0].point()?;
1227        let p11 = self.control_points[1][1].point()?;
1228        let gap = p11.sub(p10).sub(p01.sub(p00)).length();
1229        // The parallelogram defect measures the non-affine bilinear term.
1230        // Scale it by the patch extent, never its distance from the origin:
1231        // falsely accepting a translated warped patch also labels it a plane
1232        // and sends its pcurves and offsets through exact affine shortcuts.
1233        let extent = [
1234            p01.sub(p00),
1235            p10.sub(p00),
1236            p11.sub(p00),
1237            p10.sub(p01),
1238            p11.sub(p01),
1239            p11.sub(p10),
1240        ]
1241        .iter()
1242        .map(|delta| delta.length())
1243        .fold(0.0, f64::max);
1244        Ok(gap <= 1e-9 * (1.0 + extent))
1245    }
1246
1247    pub fn iso_curve_u(&self, u: f64) -> Result<NurbsCurve, String> {
1248        self.ensure_valid()?;
1249        let u = knot_clamp(&self.knots_u, self.degree_u, u);
1250        let span = knot_find_span(&self.knots_u, self.degree_u, u);
1251        let basis = if self.degree_u <= MAX_STACK_DEGREE {
1252            let mut stack = [0.0f64; MAX_STACK_ORDER];
1253            basis_functions_into(&self.knots_u, self.degree_u, span, u, &mut stack);
1254            stack[..=self.degree_u].to_vec()
1255        } else {
1256            KnotVector::new(self.knots_u.clone(), self.degree_u)?.basis_functions(span, u)
1257        };
1258        let mut points = Vec::with_capacity(self.control_points[0].len());
1259        for column in 0..self.control_points[0].len() {
1260            let mut point = Vec4 {
1261                x: 0.0,
1262                y: 0.0,
1263                z: 0.0,
1264                w: 0.0,
1265            };
1266            for (index, value) in basis.iter().enumerate() {
1267                point = point
1268                    .add(self.control_points[span - self.degree_u + index][column].scale(*value));
1269            }
1270            points.push(point);
1271        }
1272        NurbsCurve::new(self.degree_v, self.knots_v.clone(), points)
1273    }
1274
1275    pub fn iso_curve_v(&self, v: f64) -> Result<NurbsCurve, String> {
1276        self.ensure_valid()?;
1277        let v = knot_clamp(&self.knots_v, self.degree_v, v);
1278        let span = knot_find_span(&self.knots_v, self.degree_v, v);
1279        let basis = if self.degree_v <= MAX_STACK_DEGREE {
1280            let mut stack = [0.0f64; MAX_STACK_ORDER];
1281            basis_functions_into(&self.knots_v, self.degree_v, span, v, &mut stack);
1282            stack[..=self.degree_v].to_vec()
1283        } else {
1284            KnotVector::new(self.knots_v.clone(), self.degree_v)?.basis_functions(span, v)
1285        };
1286        let mut points = Vec::with_capacity(self.control_points.len());
1287        for row in &self.control_points {
1288            let mut point = Vec4 {
1289                x: 0.0,
1290                y: 0.0,
1291                z: 0.0,
1292                w: 0.0,
1293            };
1294            for (index, value) in basis.iter().enumerate() {
1295                point = point.add(row[span - self.degree_v + index].scale(*value));
1296            }
1297            points.push(point);
1298        }
1299        NurbsCurve::new(self.degree_u, self.knots_u.clone(), points)
1300    }
1301}
1302
1303pub fn make_plane(
1304    origin: Vec3,
1305    u_direction: Vec3,
1306    v_direction: Vec3,
1307    u_max: f64,
1308    v_max: f64,
1309) -> Result<NurbsSurface, String> {
1310    if u_max <= EPS || v_max <= EPS {
1311        return Err("makePlane: parameter extents must be positive".into());
1312    }
1313    let p10 = origin.add(u_direction.scale(u_max));
1314    let p01 = origin.add(v_direction.scale(v_max));
1315    let p11 = p10.add(v_direction.scale(v_max));
1316    NurbsSurface::new(
1317        1,
1318        1,
1319        vec![0.0, 0.0, u_max, u_max],
1320        vec![0.0, 0.0, v_max, v_max],
1321        vec![
1322            vec![Vec4::from_point(origin, 1.0), Vec4::from_point(p01, 1.0)],
1323            vec![Vec4::from_point(p10, 1.0), Vec4::from_point(p11, 1.0)],
1324        ],
1325    )
1326}
1327
1328pub fn make_extrusion(profile: &NurbsCurve, direction: Vec3) -> Result<NurbsSurface, String> {
1329    let rows = profile
1330        .control_points
1331        .iter()
1332        .map(|point| {
1333            vec![
1334                *point,
1335                Vec4 {
1336                    x: point.x + point.w * direction.x,
1337                    y: point.y + point.w * direction.y,
1338                    z: point.z + point.w * direction.z,
1339                    w: point.w,
1340                },
1341            ]
1342        })
1343        .collect();
1344    NurbsSurface::new(
1345        profile.degree,
1346        1,
1347        profile.knots.clone(),
1348        vec![0.0, 0.0, 1.0, 1.0],
1349        rows,
1350    )
1351}
1352
1353pub fn make_revolution(
1354    axis_point: Vec3,
1355    axis_direction: Vec3,
1356    generatrix: &NurbsCurve,
1357    theta: f64,
1358) -> Result<NurbsSurface, String> {
1359    if theta <= EPS || theta > std::f64::consts::TAU + EPS {
1360        return Err("makeRevolution: theta must be in (0, 2*PI]".into());
1361    }
1362    let theta = theta.min(std::f64::consts::TAU);
1363    let axis = axis_direction.normalized()?;
1364    let arc_count = ((theta / std::f64::consts::FRAC_PI_2 - EPS).ceil() as usize).clamp(1, 4);
1365    let arc_angle = theta / arc_count as f64;
1366    let middle_weight = (arc_angle / 2.0).cos();
1367    let mut knots_u = vec![0.0, 0.0, 0.0];
1368    for index in 1..arc_count {
1369        let knot = index as f64 / arc_count as f64;
1370        knots_u.extend([knot, knot]);
1371    }
1372    knots_u.extend([1.0, 1.0, 1.0]);
1373    let mut rows = vec![
1374        vec![
1375            Vec4 {
1376                x: 0.0,
1377                y: 0.0,
1378                z: 0.0,
1379                w: 1.0,
1380            };
1381            generatrix.control_points.len()
1382        ];
1383        2 * arc_count + 1
1384    ];
1385
1386    // How far the generatrix reaches from the axis — the scale every "is this
1387    // control point on the axis?" question below is measured against.
1388    let mut radial_extent: f64 = 0.0;
1389    for control_point in &generatrix.control_points {
1390        let point = control_point.point()?;
1391        let offset = point.sub(axis_point);
1392        radial_extent = radial_extent.max(offset.sub(axis.scale(offset.dot(axis))).length());
1393    }
1394
1395    for (column, control_point) in generatrix.control_points.iter().enumerate() {
1396        let point = control_point.point()?;
1397        let weight = control_point.w;
1398        let axis_origin = axis_point.add(axis.scale(point.sub(axis_point).dot(axis)));
1399        let radial_vector = point.sub(axis_origin);
1400        // On-axis classification must be RELATIVE like the fuzzy band below:
1401        // the absolute EPS floor alone broke when the importer's mm conversion
1402        // scaled vendor pole noise (1.5e-13 m -> 1.5e-10 mm) past it, hard-
1403        // rejecting files whose poles are intended on-axis. 1e-8 of the radial
1404        // extent snaps that noise to an EXACT pole (radius 0, so every
1405        // downstream `radius <= EPS` branch takes the degenerate path):
1406        // corpus vendor noise measures up to ~2e-9 relative, and the
1407        // 1e-6-relative nudge the fuzzy-pole test rejects stays two decades
1408        // above the snap.
1409        let measured_radius = radial_vector.length();
1410        let pole_tolerance = EPS.max(1e-8 * radial_extent);
1411        let radius = if measured_radius <= pole_tolerance {
1412            0.0
1413        } else {
1414            measured_radius
1415        };
1416        let (x_axis, y_axis) = if radius <= EPS {
1417            (Vec3::default(), Vec3::default())
1418        } else {
1419            // A control point is either ON the axis — the degenerate pole row
1420            // above, whose rotation is a fixed point — or clearly off it. In
1421            // between the revolution carries a fuzzy PINCH where a pole was
1422            // meant to be, which downstream code (analytic recognition, pair
1423            // classification, the imprint) reads as an ordinary tiny circle:
1424            // refuse it here instead. This refusal used to fall out of the
1425            // parallel-tangent guard below, whose ABSOLUTE floor made it
1426            // "radius <= 1e-3" — which on a metre-unit STEP file rejected every
1427            // healthy 1.524e-4 cylinder in the part. Relative to the
1428            // generatrix's own radial extent it catches the fuzzy pole at any
1429            // model scale and lets real geometry through at any size.
1430            if radius <= NEAR_AXIS_RELATIVE_TOLERANCE * radial_extent {
1431                return Err(format!(
1432                    "makeRevolution: generatrix control point {column} sits {radius:e} from the \
1433                     axis, neither on it nor clear of it against the generatrix's {radial_extent:e} \
1434                     radial extent (a fuzzy pole)"
1435                ));
1436            }
1437            let x_axis = radial_vector.scale(1.0 / radius);
1438            (x_axis, axis.cross(x_axis))
1439        };
1440        rows[0][column] = Vec4::from_point(point, weight);
1441        let mut start_point = point;
1442        let mut start_tangent = y_axis.scale(radius);
1443        let mut angle = 0.0;
1444        for arc in 1..=arc_count {
1445            angle += arc_angle;
1446            let end_point = if radius <= EPS {
1447                point
1448            } else {
1449                axis_origin
1450                    .add(x_axis.scale(radius * angle.cos()))
1451                    .add(y_axis.scale(radius * angle.sin()))
1452            };
1453            let end_tangent = if radius <= EPS {
1454                Vec3::default()
1455            } else {
1456                x_axis
1457                    .scale(-radius * angle.sin())
1458                    .add(y_axis.scale(radius * angle.cos()))
1459            };
1460            let row = 2 * (arc - 1);
1461            if radius <= EPS {
1462                rows[row + 1][column] = Vec4::from_point(point, weight * middle_weight);
1463            } else {
1464                let cross = start_tangent.cross(end_tangent);
1465                let denominator = cross.length_squared();
1466                // Both tangents have length `radius`, so `denominator` is
1467                // radius^4 * sin^2(arc_angle) and an ABSOLUTE floor here reads
1468                // as "radius <= 1e-3", conflating a small part with a
1469                // degenerate sweep. Parallel tangents are purely an ANGULAR
1470                // condition, so scale the floor by the tangent lengths: the
1471                // test becomes sin^2(arc_angle) <= EPS, exactly what make_arc
1472                // applies to its already-unit tangents. Near-axis control
1473                // points are the near-axis check's business, above.
1474                let tangent_scale = start_tangent.length_squared() * end_tangent.length_squared();
1475                if denominator <= EPS * tangent_scale {
1476                    return Err("makeRevolution: arc tangents are parallel".into());
1477                }
1478                let distance =
1479                    end_point.sub(start_point).cross(end_tangent).dot(cross) / denominator;
1480                let middle = start_point.add(start_tangent.scale(distance));
1481                rows[row + 1][column] = Vec4::from_point(middle, weight * middle_weight);
1482            }
1483            rows[row + 2][column] = Vec4::from_point(end_point, weight);
1484            start_point = end_point;
1485            start_tangent = end_tangent;
1486        }
1487    }
1488    NurbsSurface::new(
1489        2,
1490        generatrix.degree,
1491        knots_u,
1492        generatrix.knots.clone(),
1493        rows,
1494    )
1495}
1496
1497pub fn make_cylinder_surface(
1498    base: Vec3,
1499    axis_direction: Vec3,
1500    radius: f64,
1501    height: f64,
1502) -> Result<NurbsSurface, String> {
1503    if radius <= EPS || height <= EPS {
1504        return Err("makeCylinder: radius and height must be positive".into());
1505    }
1506    let axis = axis_direction.normalized()?;
1507    let x_axis = axis.perpendicular()?;
1508    let start = base.add(x_axis.scale(radius));
1509    let end = start.add(axis.scale(height));
1510    let generatrix = crate::make_line(start, end)?;
1511    make_revolution(base, axis, &generatrix, std::f64::consts::TAU)
1512}
1513
1514pub fn make_cone_surface(
1515    base: Vec3,
1516    axis_direction: Vec3,
1517    radius_bottom: f64,
1518    radius_top: f64,
1519    height: f64,
1520) -> Result<NurbsSurface, String> {
1521    if radius_bottom <= EPS || radius_top < 0.0 || height <= EPS {
1522        return Err("makeCone: invalid radius or height".into());
1523    }
1524    let axis = axis_direction.normalized()?;
1525    let x_axis = axis.perpendicular()?;
1526    let start = base.add(x_axis.scale(radius_bottom));
1527    let end = base.add(axis.scale(height)).add(x_axis.scale(radius_top));
1528    let generatrix = crate::make_line(start, end)?;
1529    make_revolution(base, axis, &generatrix, std::f64::consts::TAU)
1530}
1531
1532pub fn make_sphere_surface(
1533    center: Vec3,
1534    radius: f64,
1535    polar_axis: Vec3,
1536) -> Result<NurbsSurface, String> {
1537    make_sphere_surface_framed(center, radius, polar_axis, None)
1538}
1539
1540/// A sphere with BOTH halves of its frame chosen: the polar axis (where the two
1541/// degenerate poles sit) and the SEAM direction (where the `u = 0` meridian
1542/// runs). `seam_direction` is Gram-Schmidt'd against the axis; `None` — and a
1543/// seam parallel to the axis, which names no meridian — falls back to
1544/// [`Vec3::perpendicular`], i.e. exactly what [`make_sphere_surface`] builds.
1545///
1546/// A caller that KNOWS where the sphere will be trimmed uses this to park the
1547/// poles and the seam inside the region the trim removes, so the surviving face
1548/// carries neither. `feature_pipeline::features::tube` does that for its joint
1549/// balls: a seam that survives into the result is an edge bordering ONE face,
1550/// which is a real edge in the topology and a visible line on the model.
1551pub fn make_sphere_surface_framed(
1552    center: Vec3,
1553    radius: f64,
1554    polar_axis: Vec3,
1555    seam_direction: Option<Vec3>,
1556) -> Result<NurbsSurface, String> {
1557    if radius <= EPS {
1558        return Err("makeSphere: radius must be positive".into());
1559    }
1560    let axis = polar_axis.normalized()?;
1561    let x_axis = match seam_direction {
1562        Some(seam) => {
1563            let planar = seam.sub(axis.scale(seam.dot(axis)));
1564            match planar.normalized() {
1565                Ok(unit) if planar.length() > EPS => unit,
1566                _ => axis.perpendicular()?,
1567            }
1568        }
1569        None => axis.perpendicular()?,
1570    };
1571    let meridian = crate::make_arc(
1572        center,
1573        x_axis,
1574        axis,
1575        radius,
1576        -std::f64::consts::FRAC_PI_2,
1577        std::f64::consts::FRAC_PI_2,
1578    )?;
1579    make_revolution(center, axis, &meridian, std::f64::consts::TAU)
1580}
1581
1582pub fn make_torus_surface(
1583    center: Vec3,
1584    axis_direction: Vec3,
1585    major_radius: f64,
1586    minor_radius: f64,
1587) -> Result<NurbsSurface, String> {
1588    if minor_radius <= EPS || major_radius <= minor_radius {
1589        return Err("makeTorus: requires positive minorRadius < majorRadius".into());
1590    }
1591    let axis = axis_direction.normalized()?;
1592    let x_axis = axis.perpendicular()?;
1593    let tube_center = center.add(x_axis.scale(major_radius));
1594    let tube = crate::make_arc(
1595        tube_center,
1596        x_axis,
1597        axis,
1598        minor_radius,
1599        0.0,
1600        std::f64::consts::TAU,
1601    )?;
1602    make_revolution(center, axis, &tube, std::f64::consts::TAU)
1603}
1604
1605fn binomial(n: usize, k: usize) -> f64 {
1606    if k > n {
1607        return 0.0;
1608    }
1609    let k = k.min(n - k);
1610    (1..=k).fold(1.0, |value, index| {
1611        value * (n - k + index) as f64 / index as f64
1612    })
1613}
1614
1615
1616
1617/// Diagnostic "carrier preview" patch (§3.15 applied to display): re-express
1618/// the surface over an INFLATED domain so an inspector can show where the
1619/// carrier continues beyond the face's trim. Open directions inflate about
1620/// the domain centre by `inflate` (a factor; 1 = unchanged), evaluated
1621/// through `evaluate_extended` — exact linear extension for affine carriers,
1622/// ruled/tangent extension generally. CLOSED (periodic) directions keep the
1623/// stored full period instead of inflating — wrapping further would overlap
1624/// the surface onto itself. A torus (closed both ways) returns unchanged.
1625///
1626/// Affine carriers rebuild EXACTLY from four extended corners; everything
1627/// else is a preview-quality degree-3 interpolation over a 33-sample grid.
1628pub fn carrier_preview_patch(surface: &NurbsSurface, inflate: f64) -> Result<NurbsSurface, String> {
1629    let inflate = if inflate.is_finite() {
1630        inflate.clamp(1.0, 16.0)
1631    } else {
1632        2.0
1633    };
1634    let (closed_u, closed_v) = surface.closed_directions()?;
1635    let [u0, u1] = surface.domain_u()?;
1636    let [v0, v1] = surface.domain_v()?;
1637    let stretch = |d0: f64, d1: f64, closed: bool| -> (f64, f64) {
1638        if closed || inflate <= 1.0 {
1639            (d0, d1)
1640        } else {
1641            let centre = 0.5 * (d0 + d1);
1642            let half = 0.5 * (d1 - d0) * inflate;
1643            (centre - half, centre + half)
1644        }
1645    };
1646    let (nu0, nu1) = stretch(u0, u1, closed_u);
1647    let (nv0, nv1) = stretch(v0, v1, closed_v);
1648    if (nu0, nu1) == (u0, u1) && (nv0, nv1) == (v0, v1) {
1649        return Ok(surface.clone());
1650    }
1651    if surface.is_affine()? {
1652        // Exact: the linear extension of an affine patch is the same affine
1653        // map over the larger rectangle.
1654        let corners = [
1655            surface.evaluate_extended(nu0, nv0)?,
1656            surface.evaluate_extended(nu1, nv0)?,
1657            surface.evaluate_extended(nu0, nv1)?,
1658            surface.evaluate_extended(nu1, nv1)?,
1659        ];
1660        return NurbsSurface::new(
1661            1,
1662            1,
1663            vec![nu0, nu0, nu1, nu1],
1664            vec![nv0, nv0, nv1, nv1],
1665            vec![
1666                vec![
1667                    Vec4::from_point(corners[0], 1.0),
1668                    Vec4::from_point(corners[2], 1.0),
1669                ],
1670                vec![
1671                    Vec4::from_point(corners[1], 1.0),
1672                    Vec4::from_point(corners[3], 1.0),
1673                ],
1674            ],
1675        );
1676    }
1677    const SAMPLES: usize = 32;
1678    let u_params: Vec<f64> = (0..=SAMPLES)
1679        .map(|index| nu0 + (nu1 - nu0) * index as f64 / SAMPLES as f64)
1680        .collect();
1681    let v_params: Vec<f64> = (0..=SAMPLES)
1682        .map(|index| nv0 + (nv1 - nv0) * index as f64 / SAMPLES as f64)
1683        .collect();
1684    // Two-pass grid interpolation: fit each v-column, then fit the resulting
1685    // control rows across u, sharing knots per pass (identical parameters).
1686    let mut column_curves = Vec::with_capacity(u_params.len());
1687    for &u in &u_params {
1688        let column: Vec<Vec3> = v_params
1689            .iter()
1690            .map(|&v| surface.evaluate_extended(u, v))
1691            .collect::<Result<_, _>>()?;
1692        column_curves.push(crate::interpolate_curve(
1693            &column,
1694            3,
1695            &normalized(&v_params),
1696        )?);
1697    }
1698    let v_knots = column_curves[0].knots.clone();
1699    let control_rows = column_curves[0].control_points.len();
1700    let mut grid: Vec<Vec<Vec4>> = Vec::with_capacity(u_params.len());
1701    let mut u_knots = Vec::new();
1702    for row_index in 0..control_rows {
1703        let row: Vec<Vec3> = column_curves
1704            .iter()
1705            .map(|curve| curve.control_points[row_index].point())
1706            .collect::<Result<_, _>>()?;
1707        let fitted = crate::interpolate_curve(&row, 3, &normalized(&u_params))?;
1708        u_knots = fitted.knots.clone();
1709        for (u_index, control) in fitted.control_points.iter().enumerate() {
1710            if grid.len() <= u_index {
1711                grid.push(Vec::new());
1712            }
1713            grid[u_index].push(*control);
1714        }
1715    }
1716    // Re-scale the normalized fit knots back onto the inflated domains so the
1717    // preview's parameter space lines up with the original surface's.
1718    let rescale = |knots: &[f64], d0: f64, d1: f64| -> Vec<f64> {
1719        knots.iter().map(|k| d0 + (d1 - d0) * k).collect()
1720    };
1721    NurbsSurface::new(
1722        3,
1723        3,
1724        rescale(&u_knots, nu0, nu1),
1725        rescale(&v_knots, nv0, nv1),
1726        grid,
1727    )
1728}
1729
1730fn normalized(parameters: &[f64]) -> Vec<f64> {
1731    let first = parameters[0];
1732    let last = parameters[parameters.len() - 1];
1733    parameters
1734        .iter()
1735        .map(|p| (p - first) / (last - first))
1736        .collect()
1737}
1738
1739
1740
1741
1742
1743fn analytic_deriv1_on() -> bool {
1744    switch!("BREP_ANALYTIC_DERIV1", true)
1745}
1746
1747
1748/// Default-off until the evaluator's bitwise and complete-body gates qualify it.
1749fn analytic_point_on() -> bool {
1750    switch!("BREP_ANALYTIC_POINT", false)
1751}
1752
1753/// SMALL DERIVATIVES (lane O row 20, 2026-10-29; on by default since the batch 5
1754/// promotion, `BREP_SURFACE_SMALL_DERIVATIVES=0` reads the heap grid): `evaluate_extended`, `derivatives_extended` up to second order,
1755/// `normal` and `principal_curvatures` read the stack twin
1756/// [`NurbsSurface::derivatives_small`] instead of the heap grid. A third of the
1757/// D2 outward shell's heap allocations were `NurbsSurface::derivatives` (a
1758/// sampled-backtrace allocation census, cad-bot); the values are the same bits.
1759pub(crate) fn small_derivatives_enabled() -> bool {
1760    switch!("BREP_SURFACE_SMALL_DERIVATIVES", true)
1761}
1762
1763