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