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