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