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