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