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