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
8const EPS: f64 = 1e-12;
9/// A generatrix control point that is neither ON the axis of revolution nor
10/// this fraction of the generatrix's own radial extent clear of it is a fuzzy
11/// pole, and `make_revolution` refuses it (see the comment at the check).
12const NEAR_AXIS_RELATIVE_TOLERANCE: f64 = 1e-4;
13
14#[derive(Clone, Debug, Deserialize, Serialize)]
15pub struct NurbsSurface {
16    pub degree_u: usize,
17    pub degree_v: usize,
18    pub knots_u: Vec<f64>,
19    pub knots_v: Vec<f64>,
20    pub control_points: Vec<Vec<Vec4>>,
21    /// Lazily recognized analytic carrier (plane/cylinder/cone/sphere/torus).
22    /// Purely an acceleration cache: never serialized, cloned with the
23    /// surface, and recomputed on demand after deserialization.
24    #[serde(skip, default)]
25    analytic: std::cell::OnceCell<Option<crate::AnalyticSurface>>,
26    /// One-time validation cache (see `NurbsCurve::ensure_valid`).
27    #[serde(skip, default)]
28    validated: std::cell::Cell<bool>,
29    /// Cached (closed_u, closed_v) seam test — a pure function of the
30    /// immutable geometry, recomputed on demand after deserialization.
31    #[serde(skip, default)]
32    closed_directions: std::cell::OnceCell<(bool, bool)>,
33    /// Cached Newton seed grid for general point projection (see
34    /// `projection.rs`) — (u, v, point) samples over the knot spans.
35    #[serde(skip, default)]
36    pub(crate) projection_grid: std::cell::OnceCell<Vec<(f64, f64, Vec3)>>,
37    /// Cached dense fallback grid for degenerate general projection
38    /// (`projection.rs`) — (u, v, point) samples at fixed per-surface
39    /// parameters, so the ~1.7M-call metre-mm fallback reuses the evals.
40    #[serde(skip, default)]
41    pub(crate) projection_dense_grid: std::cell::OnceCell<Vec<(f64, f64, Vec3)>>,
42    /// Cached boundary-ring sample points for the degenerate projection
43    /// fallback (`projection.rs`), flattened as 4 sides × 22 exponents ×
44    /// 33 indices of evaluated surface points at fixed per-surface params.
45    #[serde(skip, default)]
46    pub(crate) projection_ring_grid: std::cell::OnceCell<Vec<Vec3>>,
47}
48
49impl NurbsSurface {
50    pub fn new(
51        degree_u: usize,
52        degree_v: usize,
53        knots_u: Vec<f64>,
54        knots_v: Vec<f64>,
55        control_points: Vec<Vec<Vec4>>,
56    ) -> Result<Self, String> {
57        let surface = Self {
58            degree_u,
59            degree_v,
60            knots_u,
61            knots_v,
62            control_points,
63            analytic: std::cell::OnceCell::new(),
64            validated: std::cell::Cell::new(false),
65            closed_directions: std::cell::OnceCell::new(),
66            projection_grid: std::cell::OnceCell::new(),
67            projection_dense_grid: std::cell::OnceCell::new(),
68            projection_ring_grid: std::cell::OnceCell::new(),
69        };
70        surface.ensure_valid()?;
71        Ok(surface)
72    }
73
74    /// Whether the surface joins itself along u (first) and v (second):
75    /// opposite domain edges coincide at three sampled fractions. The 1e-6
76    /// threshold matches the historical `LINEAR_TOLERANCE * 10` used by the
77    /// projection and curve×surface intersection modules.
78    pub fn closed_directions(&self) -> Result<(bool, bool), String> {
79        if let Some(&cached) = self.closed_directions.get() {
80            return Ok(cached);
81        }
82        const CLOSED_SEAM_TOLERANCE: f64 = 1e-6;
83        let [u0, u1] = self.domain_u()?;
84        let [v0, v1] = self.domain_v()?;
85        let closed_along = |direction_u: bool| -> Result<bool, String> {
86            for fraction in [0.17, 0.5, 0.83] {
87                let (a, b) = if direction_u {
88                    let v = v0 + (v1 - v0) * fraction;
89                    (self.evaluate(u0, v)?, self.evaluate(u1, v)?)
90                } else {
91                    let u = u0 + (u1 - u0) * fraction;
92                    (self.evaluate(u, v0)?, self.evaluate(u, v1)?)
93                };
94                if a.sub(b).length() > CLOSED_SEAM_TOLERANCE {
95                    return Ok(false);
96                }
97            }
98            Ok(true)
99        };
100        let value = (closed_along(true)?, closed_along(false)?);
101        Ok(*self.closed_directions.get_or_init(|| value))
102    }
103
104    /// The full construction-time checks, run at most once per instance.
105    fn ensure_valid(&self) -> Result<(), String> {
106        if self.validated.get() {
107            return Ok(());
108        }
109        validate_knots(&self.knots_u, self.degree_u)?;
110        validate_knots(&self.knots_v, self.degree_v)?;
111        let rows = self.knots_u.len() - self.degree_u - 1;
112        let columns = self.knots_v.len() - self.degree_v - 1;
113        if self.control_points.len() != rows {
114            return Err(format!(
115                "NurbsSurface: u knot vector implies {} rows, got {}",
116                rows,
117                self.control_points.len()
118            ));
119        }
120        for row in &self.control_points {
121            if row.len() != columns {
122                return Err(format!(
123                    "NurbsSurface: v knot vector implies {} columns, got {}",
124                    columns,
125                    row.len()
126                ));
127            }
128            if row.iter().any(|point| {
129                point.w <= EPS
130                    || ![point.x, point.y, point.z, point.w]
131                        .iter()
132                        .all(|value| value.is_finite())
133            }) {
134                return Err(
135                    "NurbsSurface: control points must be finite with positive weights".into(),
136                );
137            }
138        }
139        self.validated.set(true);
140        Ok(())
141    }
142
143    pub fn domain_u(&self) -> Result<[f64; 2], String> {
144        self.ensure_valid()?;
145        Ok(knot_domain(&self.knots_u, self.degree_u))
146    }
147
148    pub fn domain_v(&self) -> Result<[f64; 2], String> {
149        self.ensure_valid()?;
150        Ok(knot_domain(&self.knots_v, self.degree_v))
151    }
152
153    /// The recognized analytic carrier, if this exact rational patch is a
154    /// plane or a full revolution quadric/torus. Computed once per instance.
155    pub fn analytic(&self) -> Option<&crate::AnalyticSurface> {
156        self.analytic
157            .get_or_init(|| crate::analytic_surface::recognize(self))
158            .as_ref()
159    }
160
161    fn knot_vectors(&self) -> Result<(KnotVector, KnotVector), String> {
162        Ok((
163            KnotVector::new(self.knots_u.clone(), self.degree_u)?,
164            KnotVector::new(self.knots_v.clone(), self.degree_v)?,
165        ))
166    }
167
168    pub fn evaluate_homogeneous(&self, u: f64, v: f64) -> Result<Vec4, String> {
169        self.ensure_valid()?;
170        if self.degree_u > MAX_STACK_DEGREE || self.degree_v > MAX_STACK_DEGREE {
171            return self.evaluate_homogeneous_heap(u, v);
172        }
173        let u = knot_clamp(&self.knots_u, self.degree_u, u);
174        let v = knot_clamp(&self.knots_v, self.degree_v, v);
175        let span_u = knot_find_span(&self.knots_u, self.degree_u, u);
176        let span_v = knot_find_span(&self.knots_v, self.degree_v, v);
177        let mut basis_u = [0.0f64; MAX_STACK_ORDER];
178        let mut basis_v = [0.0f64; MAX_STACK_ORDER];
179        basis_functions_into(&self.knots_u, self.degree_u, span_u, u, &mut basis_u);
180        basis_functions_into(&self.knots_v, self.degree_v, span_v, v, &mut basis_v);
181        let mut point = Vec4 {
182            x: 0.0,
183            y: 0.0,
184            z: 0.0,
185            w: 0.0,
186        };
187        for (i, value_u) in basis_u[..=self.degree_u].iter().enumerate() {
188            let row = &self.control_points[span_u - self.degree_u + i];
189            for (j, value_v) in basis_v[..=self.degree_v].iter().enumerate() {
190                point = point.add(row[span_v - self.degree_v + j].scale(value_u * value_v));
191            }
192        }
193        Ok(point)
194    }
195
196    fn evaluate_homogeneous_heap(&self, u: f64, v: f64) -> Result<Vec4, String> {
197        let (knot_u, knot_v) = self.knot_vectors()?;
198        let u = knot_u.clamp_param(u);
199        let v = knot_v.clamp_param(v);
200        let span_u = knot_u.find_span(u);
201        let span_v = knot_v.find_span(v);
202        let basis_u = knot_u.basis_functions(span_u, u);
203        let basis_v = knot_v.basis_functions(span_v, v);
204        let mut point = Vec4 {
205            x: 0.0,
206            y: 0.0,
207            z: 0.0,
208            w: 0.0,
209        };
210        for (i, value_u) in basis_u.iter().enumerate() {
211            let row = &self.control_points[span_u - self.degree_u + i];
212            for (j, value_v) in basis_v.iter().enumerate() {
213                point = point.add(row[span_v - self.degree_v + j].scale(value_u * value_v));
214            }
215        }
216        Ok(point)
217    }
218
219    pub fn evaluate(&self, u: f64, v: f64) -> Result<Vec3, String> {
220        self.evaluate_homogeneous(u, v)?.point()
221    }
222
223    /// Value beyond the domain (Golovanov §3.15): closed directions wrap
224    /// cyclically; open directions extend along the boundary tangent
225    /// plane, with the bilinear corner form when both parameters are
226    /// outside.  Intersection and projection algorithms probe outside the
227    /// domain, so this is a required capability of every surface.
228    pub fn evaluate_extended(&self, u: f64, v: f64) -> Result<Vec3, String> {
229        Ok(self.derivatives_extended(u, v, 0)?[0][0])
230    }
231
232    /// Derivatives beyond the domain (§3.15).  The extension is C¹: first
233    /// derivatives follow the ruled/bilinear extension, second derivatives
234    /// are taken at the boundary anchor (zero across an extended
235    /// direction).
236    pub fn derivatives_extended(
237        &self,
238        u: f64,
239        v: f64,
240        derivative_count: usize,
241    ) -> Result<Vec<Vec<Vec3>>, String> {
242        let [u0, u1] = self.domain_u()?;
243        let [v0, v1] = self.domain_v()?;
244        let (closed_u, closed_v) = self.closed_directions()?;
245        let mut uu = u;
246        let mut vv = v;
247        if closed_u && (u < u0 || u > u1) {
248            uu = u0 + (u - u0).rem_euclid(u1 - u0);
249        }
250        if closed_v && (v < v0 || v > v1) {
251            vv = v0 + (v - v0).rem_euclid(v1 - v0);
252        }
253        let du_out = if uu < u0 {
254            uu - u0
255        } else if uu > u1 {
256            uu - u1
257        } else {
258            0.0
259        };
260        let dv_out = if vv < v0 {
261            vv - v0
262        } else if vv > v1 {
263            vv - v1
264        } else {
265            0.0
266        };
267        if du_out == 0.0 && dv_out == 0.0 {
268            return self.derivatives(uu, vv, derivative_count);
269        }
270        let anchor_u = uu - du_out;
271        let anchor_v = vv - dv_out;
272        let base = self.derivatives(anchor_u, anchor_v, derivative_count.max(1))?;
273        let order = derivative_count + 1;
274        let mut result = vec![vec![Vec3::default(); order]; order];
275        // Bilinear extension: S = A00 + du·A10 + dv·A01 + du·dv·A11
276        // (reduces to the ruled tangent-plane extension when only one
277        // parameter is outside).
278        result[0][0] = base[0][0]
279            .add(base[1][0].scale(du_out))
280            .add(base[0][1].scale(dv_out))
281            .add(base[1][1].scale(du_out * dv_out));
282        if derivative_count >= 1 {
283            result[1][0] = base[1][0].add(base[1][1].scale(dv_out));
284            result[0][1] = base[0][1].add(base[1][1].scale(du_out));
285            result[1][1] = base[1][1];
286        }
287        if derivative_count >= 2 {
288            let second = self.derivatives(anchor_u, anchor_v, 2)?;
289            if du_out == 0.0 {
290                result[2][0] = second[2][0];
291            }
292            if dv_out == 0.0 {
293                result[0][2] = second[0][2];
294            }
295        }
296        Ok(result)
297    }
298
299    pub fn derivatives(
300        &self,
301        u: f64,
302        v: f64,
303        derivative_count: usize,
304    ) -> Result<Vec<Vec<Vec3>>, String> {
305        self.ensure_valid()?;
306        let du = derivative_count.min(self.degree_u);
307        let dv = derivative_count.min(self.degree_v);
308        let stride = derivative_count + 1;
309        // Flat (k, l) grid: one allocation instead of nested per-row Vecs.
310        let zero = Vec4 {
311            x: 0.0,
312            y: 0.0,
313            z: 0.0,
314            w: 0.0,
315        };
316        let mut homogeneous = vec![zero; stride * stride];
317        if self.degree_u <= MAX_STACK_DEGREE && self.degree_v <= MAX_STACK_DEGREE {
318            let u = knot_clamp(&self.knots_u, self.degree_u, u);
319            let v = knot_clamp(&self.knots_v, self.degree_v, v);
320            let span_u = knot_find_span(&self.knots_u, self.degree_u, u);
321            let span_v = knot_find_span(&self.knots_v, self.degree_v, v);
322            let mut basis_u = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
323            let mut basis_v = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
324            basis_derivatives_into(
325                &self.knots_u,
326                self.degree_u,
327                span_u,
328                u,
329                du,
330                &mut basis_u[..=du],
331            );
332            basis_derivatives_into(
333                &self.knots_v,
334                self.degree_v,
335                span_v,
336                v,
337                dv,
338                &mut basis_v[..=dv],
339            );
340            for k in 0..=du {
341                for l in 0..=dv {
342                    if k + l > derivative_count {
343                        continue;
344                    }
345                    let mut point = zero;
346                    for i in 0..=self.degree_u {
347                        let row = &self.control_points[span_u - self.degree_u + i];
348                        for j in 0..=self.degree_v {
349                            point = point.add(
350                                row[span_v - self.degree_v + j]
351                                    .scale(basis_u[k][i] * basis_v[l][j]),
352                            );
353                        }
354                    }
355                    homogeneous[k * stride + l] = point;
356                }
357            }
358        } else {
359            let (knot_u, knot_v) = self.knot_vectors()?;
360            let u = knot_u.clamp_param(u);
361            let v = knot_v.clamp_param(v);
362            let span_u = knot_u.find_span(u);
363            let span_v = knot_v.find_span(v);
364            let basis_u = knot_u.basis_derivatives(span_u, u, du);
365            let basis_v = knot_v.basis_derivatives(span_v, v, dv);
366            for k in 0..=du {
367                for l in 0..=dv {
368                    if k + l > derivative_count {
369                        continue;
370                    }
371                    let mut point = zero;
372                    for i in 0..=self.degree_u {
373                        let row = &self.control_points[span_u - self.degree_u + i];
374                        for j in 0..=self.degree_v {
375                            point = point.add(
376                                row[span_v - self.degree_v + j]
377                                    .scale(basis_u[k][i] * basis_v[l][j]),
378                            );
379                        }
380                    }
381                    homogeneous[k * stride + l] = point;
382                }
383            }
384        }
385
386        let mut result = vec![vec![Vec3::default(); derivative_count + 1]; derivative_count + 1];
387        let weight = homogeneous[0].w;
388        if weight.abs() <= EPS {
389            return Err("NurbsSurface: zero evaluated weight".into());
390        }
391        for k in 0..=derivative_count {
392            for l in 0..=derivative_count - k {
393                if k > du || l > dv {
394                    continue;
395                }
396                let at = |row: usize, column: usize| homogeneous[row * stride + column];
397                let mut value = Vec3::new(at(k, l).x, at(k, l).y, at(k, l).z);
398                for j in 1..=l {
399                    value = value.sub(result[k][l - j].scale(binomial(l, j) * at(0, j).w));
400                }
401                for i in 1..=k {
402                    value = value.sub(result[k - i][l].scale(binomial(k, i) * at(i, 0).w));
403                    let mut mixed = Vec3::default();
404                    for j in 1..=l {
405                        mixed = mixed.add(result[k - i][l - j].scale(binomial(l, j) * at(i, j).w));
406                    }
407                    value = value.sub(mixed.scale(binomial(k, i)));
408                }
409                result[k][l] = value.scale(1.0 / weight);
410            }
411        }
412        Ok(result)
413    }
414
415    /// Allocation-free twin of [`Self::derivatives`] for the hot
416    /// `derivative_count <= 2` path (point, first, and second partials).
417    ///
418    /// This is a byte-for-byte faithful copy of the arithmetic in
419    /// [`Self::derivatives`]: identical basis evaluation, identical summation
420    /// order (control-point row `i` outer, column `j` inner, `scale`-then-`add`),
421    /// and the identical rational `A(u,v)/w(u,v)` de-homogenization recurrence.
422    /// Only the storage differs — fixed-size stack arrays (`[Vec4; 9]` for the
423    /// `(count+1)²` homogeneous grid, `[[Vec3; 3]; 3]` for the result) replace
424    /// the heap `Vec<Vec4>` / `Vec<Vec<Vec3>>`. Grid entries outside
425    /// `k + l <= derivative_count` (and outside `k <= du`, `l <= dv`) stay
426    /// `Vec3::default()`, exactly as the heap version leaves them, so callers may
427    /// index `[k][l]` for any `k + l <= 2` and read the same value the Vec API
428    /// would have produced.
429    pub(crate) fn derivatives_small(
430        &self,
431        u: f64,
432        v: f64,
433        derivative_count: usize,
434    ) -> Result<[[Vec3; 3]; 3], String> {
435        debug_assert!(derivative_count <= 2);
436        self.ensure_valid()?;
437        let du = derivative_count.min(self.degree_u);
438        let dv = derivative_count.min(self.degree_v);
439        let stride = derivative_count + 1;
440        let zero = Vec4 {
441            x: 0.0,
442            y: 0.0,
443            z: 0.0,
444            w: 0.0,
445        };
446        let mut homogeneous = [zero; 9];
447        if self.degree_u <= MAX_STACK_DEGREE && self.degree_v <= MAX_STACK_DEGREE {
448            let u = knot_clamp(&self.knots_u, self.degree_u, u);
449            let v = knot_clamp(&self.knots_v, self.degree_v, v);
450            let span_u = knot_find_span(&self.knots_u, self.degree_u, u);
451            let span_v = knot_find_span(&self.knots_v, self.degree_v, v);
452            let mut basis_u = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
453            let mut basis_v = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
454            basis_derivatives_into(
455                &self.knots_u,
456                self.degree_u,
457                span_u,
458                u,
459                du,
460                &mut basis_u[..=du],
461            );
462            basis_derivatives_into(
463                &self.knots_v,
464                self.degree_v,
465                span_v,
466                v,
467                dv,
468                &mut basis_v[..=dv],
469            );
470            for k in 0..=du {
471                for l in 0..=dv {
472                    if k + l > derivative_count {
473                        continue;
474                    }
475                    let mut point = zero;
476                    for i in 0..=self.degree_u {
477                        let row = &self.control_points[span_u - self.degree_u + i];
478                        for j in 0..=self.degree_v {
479                            point = point.add(
480                                row[span_v - self.degree_v + j]
481                                    .scale(basis_u[k][i] * basis_v[l][j]),
482                            );
483                        }
484                    }
485                    homogeneous[k * stride + l] = point;
486                }
487            }
488        } else {
489            let (knot_u, knot_v) = self.knot_vectors()?;
490            let u = knot_u.clamp_param(u);
491            let v = knot_v.clamp_param(v);
492            let span_u = knot_u.find_span(u);
493            let span_v = knot_v.find_span(v);
494            let basis_u = knot_u.basis_derivatives(span_u, u, du);
495            let basis_v = knot_v.basis_derivatives(span_v, v, dv);
496            for k in 0..=du {
497                for l in 0..=dv {
498                    if k + l > derivative_count {
499                        continue;
500                    }
501                    let mut point = zero;
502                    for i in 0..=self.degree_u {
503                        let row = &self.control_points[span_u - self.degree_u + i];
504                        for j in 0..=self.degree_v {
505                            point = point.add(
506                                row[span_v - self.degree_v + j]
507                                    .scale(basis_u[k][i] * basis_v[l][j]),
508                            );
509                        }
510                    }
511                    homogeneous[k * stride + l] = point;
512                }
513            }
514        }
515
516        let mut result = [[Vec3::default(); 3]; 3];
517        let weight = homogeneous[0].w;
518        if weight.abs() <= EPS {
519            return Err("NurbsSurface: zero evaluated weight".into());
520        }
521        for k in 0..=derivative_count {
522            for l in 0..=derivative_count - k {
523                if k > du || l > dv {
524                    continue;
525                }
526                let at = |row: usize, column: usize| homogeneous[row * stride + column];
527                let mut value = Vec3::new(at(k, l).x, at(k, l).y, at(k, l).z);
528                for j in 1..=l {
529                    value = value.sub(result[k][l - j].scale(binomial(l, j) * at(0, j).w));
530                }
531                for i in 1..=k {
532                    value = value.sub(result[k - i][l].scale(binomial(k, i) * at(i, 0).w));
533                    let mut mixed = Vec3::default();
534                    for j in 1..=l {
535                        mixed = mixed.add(result[k - i][l - j].scale(binomial(l, j) * at(i, j).w));
536                    }
537                    value = value.sub(mixed.scale(binomial(k, i)));
538                }
539                result[k][l] = value.scale(1.0 / weight);
540            }
541        }
542        Ok(result)
543    }
544
545    /// Point and first partials `(S, S_u, S_v)` with zero heap allocation.
546    /// Bit-identical to `derivatives(u, v, 1)` at `[0][0] / [1][0] / [0][1]`.
547    #[inline]
548    pub(crate) fn deriv1(&self, u: f64, v: f64) -> Result<(Vec3, Vec3, Vec3), String> {
549        let g = self.derivatives_small(u, v, 1)?;
550        Ok((g[0][0], g[1][0], g[0][1]))
551    }
552
553    /// Allocation-free `(S, S_u, S_v)` twin of `derivatives_extended(u, v, 1)`.
554    ///
555    /// Mirrors the exact C¹ ruled/bilinear extension arithmetic of
556    /// [`Self::derivatives_extended`] (only the count == 1 partials), but drives
557    /// it from [`Self::derivatives_small`] so the in-domain and boundary-anchor
558    /// evaluations allocate nothing. Bit-identical to
559    /// `derivatives_extended(u, v, 1)` at `[0][0] / [1][0] / [0][1]`.
560    pub(crate) fn deriv1_extended(&self, u: f64, v: f64) -> Result<(Vec3, Vec3, Vec3), String> {
561        let [u0, u1] = self.domain_u()?;
562        let [v0, v1] = self.domain_v()?;
563        let (closed_u, closed_v) = self.closed_directions()?;
564        let mut uu = u;
565        let mut vv = v;
566        if closed_u && (u < u0 || u > u1) {
567            uu = u0 + (u - u0).rem_euclid(u1 - u0);
568        }
569        if closed_v && (v < v0 || v > v1) {
570            vv = v0 + (v - v0).rem_euclid(v1 - v0);
571        }
572        let du_out = if uu < u0 {
573            uu - u0
574        } else if uu > u1 {
575            uu - u1
576        } else {
577            0.0
578        };
579        let dv_out = if vv < v0 {
580            vv - v0
581        } else if vv > v1 {
582            vv - v1
583        } else {
584            0.0
585        };
586        if du_out == 0.0 && dv_out == 0.0 {
587            let g = self.derivatives_small(uu, vv, 1)?;
588            return Ok((g[0][0], g[1][0], g[0][1]));
589        }
590        let anchor_u = uu - du_out;
591        let anchor_v = vv - dv_out;
592        let base = self.derivatives_small(anchor_u, anchor_v, 1)?;
593        // Bilinear extension: S = A00 + du·A10 + dv·A01 + du·dv·A11.
594        // With count == 1, `base[1][1]` is `Vec3::default()`, exactly as the
595        // Vec path's `base` (derivatives(..., 1)) leaves it.
596        let s = base[0][0]
597            .add(base[1][0].scale(du_out))
598            .add(base[0][1].scale(dv_out))
599            .add(base[1][1].scale(du_out * dv_out));
600        let su = base[1][0].add(base[1][1].scale(dv_out));
601        let sv = base[0][1].add(base[1][1].scale(du_out));
602        Ok((s, su, sv))
603    }
604
605    pub fn normal(&self, u: f64, v: f64) -> Result<Vec3, String> {
606        let derivatives = self.derivatives(u, v, 1)?;
607        derivatives[1][0].cross(derivatives[0][1]).normalized()
608    }
609
610    /// Principal curvatures (κ_min, κ_max) at (u, v), signed with respect to
611    /// the parametrization normal n = Su × Sv / |Su × Sv| via the shape
612    /// operator I⁻¹·II.  Convention check: a cylinder of radius R whose normal
613    /// points AWAY from the axis has κ = −1/R along the circular direction, so
614    /// an offset's regularity factor 1 − d·κ vanishes exactly when an inward
615    /// offset (d = −R) reaches the axis.
616    pub fn principal_curvatures(&self, u: f64, v: f64) -> Result<(f64, f64), String> {
617        let derivatives = self.derivatives(u, v, 2)?;
618        let su = derivatives[1][0];
619        let sv = derivatives[0][1];
620        let cross = su.cross(sv);
621        let cross_length = cross.length();
622        if cross_length <= 1e-12 {
623            return Err(format!(
624                "degenerate parametrization at (u={u:.4}, v={v:.4})"
625            ));
626        }
627        let normal = cross.scale(1.0 / cross_length);
628        let e1 = su.dot(su);
629        let f1 = su.dot(sv);
630        let g1 = sv.dot(sv);
631        let l2 = derivatives[2][0].dot(normal);
632        let m2 = derivatives[1][1].dot(normal);
633        let n2 = derivatives[0][2].dot(normal);
634        let denominator = e1 * g1 - f1 * f1;
635        let mean_double = (l2 * g1 - 2.0 * m2 * f1 + n2 * e1) / denominator; // 2H
636        let gauss = (l2 * n2 - m2 * m2) / denominator; // K
637        let discriminant = (mean_double * mean_double * 0.25 - gauss).max(0.0).sqrt();
638        Ok((
639            mean_double * 0.5 - discriminant,
640            mean_double * 0.5 + discriminant,
641        ))
642    }
643
644    /// Whether this is a genuinely affine degree-1 by degree-1 patch.
645    ///
646    /// A tapered bilinear patch is planar but not affine, so degree alone is
647    /// insufficient for exact inverse-parameter and integration shortcuts.
648    pub fn is_affine(&self) -> Result<bool, String> {
649        if self.degree_u != 1
650            || self.degree_v != 1
651            || self.control_points.len() != 2
652            || self.control_points[0].len() != 2
653            || self.control_points[1].len() != 2
654        {
655            return Ok(false);
656        }
657        let weight = self.control_points[0][0].w;
658        if [
659            self.control_points[0][1].w,
660            self.control_points[1][0].w,
661            self.control_points[1][1].w,
662        ]
663        .iter()
664        .any(|other| (*other - weight).abs() > 1e-12 * other.abs().max(weight.abs()))
665        {
666            return Ok(false);
667        }
668        let p00 = self.control_points[0][0].point()?;
669        let p01 = self.control_points[0][1].point()?;
670        let p10 = self.control_points[1][0].point()?;
671        let p11 = self.control_points[1][1].point()?;
672        let gap = p11.sub(p10).sub(p01.sub(p00)).length();
673        // The parallelogram defect measures the non-affine bilinear term.
674        // Scale it by the patch extent, never its distance from the origin:
675        // falsely accepting a translated warped patch also labels it a plane
676        // and sends its pcurves and offsets through exact affine shortcuts.
677        let extent = [
678            p01.sub(p00),
679            p10.sub(p00),
680            p11.sub(p00),
681            p10.sub(p01),
682            p11.sub(p01),
683            p11.sub(p10),
684        ]
685        .iter()
686        .map(|delta| delta.length())
687        .fold(0.0, f64::max);
688        Ok(gap <= 1e-9 * (1.0 + extent))
689    }
690
691    pub fn iso_curve_u(&self, u: f64) -> Result<NurbsCurve, String> {
692        self.ensure_valid()?;
693        let u = knot_clamp(&self.knots_u, self.degree_u, u);
694        let span = knot_find_span(&self.knots_u, self.degree_u, u);
695        let basis = if self.degree_u <= MAX_STACK_DEGREE {
696            let mut stack = [0.0f64; MAX_STACK_ORDER];
697            basis_functions_into(&self.knots_u, self.degree_u, span, u, &mut stack);
698            stack[..=self.degree_u].to_vec()
699        } else {
700            KnotVector::new(self.knots_u.clone(), self.degree_u)?.basis_functions(span, u)
701        };
702        let mut points = Vec::with_capacity(self.control_points[0].len());
703        for column in 0..self.control_points[0].len() {
704            let mut point = Vec4 {
705                x: 0.0,
706                y: 0.0,
707                z: 0.0,
708                w: 0.0,
709            };
710            for (index, value) in basis.iter().enumerate() {
711                point = point
712                    .add(self.control_points[span - self.degree_u + index][column].scale(*value));
713            }
714            points.push(point);
715        }
716        NurbsCurve::new(self.degree_v, self.knots_v.clone(), points)
717    }
718
719    pub fn iso_curve_v(&self, v: f64) -> Result<NurbsCurve, String> {
720        self.ensure_valid()?;
721        let v = knot_clamp(&self.knots_v, self.degree_v, v);
722        let span = knot_find_span(&self.knots_v, self.degree_v, v);
723        let basis = if self.degree_v <= MAX_STACK_DEGREE {
724            let mut stack = [0.0f64; MAX_STACK_ORDER];
725            basis_functions_into(&self.knots_v, self.degree_v, span, v, &mut stack);
726            stack[..=self.degree_v].to_vec()
727        } else {
728            KnotVector::new(self.knots_v.clone(), self.degree_v)?.basis_functions(span, v)
729        };
730        let mut points = Vec::with_capacity(self.control_points.len());
731        for row in &self.control_points {
732            let mut point = Vec4 {
733                x: 0.0,
734                y: 0.0,
735                z: 0.0,
736                w: 0.0,
737            };
738            for (index, value) in basis.iter().enumerate() {
739                point = point.add(row[span - self.degree_v + index].scale(*value));
740            }
741            points.push(point);
742        }
743        NurbsCurve::new(self.degree_u, self.knots_u.clone(), points)
744    }
745}
746
747pub fn make_plane(
748    origin: Vec3,
749    u_direction: Vec3,
750    v_direction: Vec3,
751    u_max: f64,
752    v_max: f64,
753) -> Result<NurbsSurface, String> {
754    if u_max <= EPS || v_max <= EPS {
755        return Err("makePlane: parameter extents must be positive".into());
756    }
757    let p10 = origin.add(u_direction.scale(u_max));
758    let p01 = origin.add(v_direction.scale(v_max));
759    let p11 = p10.add(v_direction.scale(v_max));
760    NurbsSurface::new(
761        1,
762        1,
763        vec![0.0, 0.0, u_max, u_max],
764        vec![0.0, 0.0, v_max, v_max],
765        vec![
766            vec![Vec4::from_point(origin, 1.0), Vec4::from_point(p01, 1.0)],
767            vec![Vec4::from_point(p10, 1.0), Vec4::from_point(p11, 1.0)],
768        ],
769    )
770}
771
772pub fn make_extrusion(profile: &NurbsCurve, direction: Vec3) -> Result<NurbsSurface, String> {
773    let rows = profile
774        .control_points
775        .iter()
776        .map(|point| {
777            vec![
778                *point,
779                Vec4 {
780                    x: point.x + point.w * direction.x,
781                    y: point.y + point.w * direction.y,
782                    z: point.z + point.w * direction.z,
783                    w: point.w,
784                },
785            ]
786        })
787        .collect();
788    NurbsSurface::new(
789        profile.degree,
790        1,
791        profile.knots.clone(),
792        vec![0.0, 0.0, 1.0, 1.0],
793        rows,
794    )
795}
796
797pub fn make_revolution(
798    axis_point: Vec3,
799    axis_direction: Vec3,
800    generatrix: &NurbsCurve,
801    theta: f64,
802) -> Result<NurbsSurface, String> {
803    if theta <= EPS || theta > std::f64::consts::TAU + EPS {
804        return Err("makeRevolution: theta must be in (0, 2*PI]".into());
805    }
806    let theta = theta.min(std::f64::consts::TAU);
807    let axis = axis_direction.normalized()?;
808    let arc_count = ((theta / std::f64::consts::FRAC_PI_2 - EPS).ceil() as usize).clamp(1, 4);
809    let arc_angle = theta / arc_count as f64;
810    let middle_weight = (arc_angle / 2.0).cos();
811    let mut knots_u = vec![0.0, 0.0, 0.0];
812    for index in 1..arc_count {
813        let knot = index as f64 / arc_count as f64;
814        knots_u.extend([knot, knot]);
815    }
816    knots_u.extend([1.0, 1.0, 1.0]);
817    let mut rows = vec![
818        vec![
819            Vec4 {
820                x: 0.0,
821                y: 0.0,
822                z: 0.0,
823                w: 1.0,
824            };
825            generatrix.control_points.len()
826        ];
827        2 * arc_count + 1
828    ];
829
830    // How far the generatrix reaches from the axis — the scale every "is this
831    // control point on the axis?" question below is measured against.
832    let mut radial_extent: f64 = 0.0;
833    for control_point in &generatrix.control_points {
834        let point = control_point.point()?;
835        let offset = point.sub(axis_point);
836        radial_extent = radial_extent.max(offset.sub(axis.scale(offset.dot(axis))).length());
837    }
838
839    for (column, control_point) in generatrix.control_points.iter().enumerate() {
840        let point = control_point.point()?;
841        let weight = control_point.w;
842        let axis_origin = axis_point.add(axis.scale(point.sub(axis_point).dot(axis)));
843        let radial_vector = point.sub(axis_origin);
844        // On-axis classification must be RELATIVE like the fuzzy band below:
845        // the absolute EPS floor alone broke when the importer's mm conversion
846        // scaled vendor pole noise (1.5e-13 m -> 1.5e-10 mm) past it, hard-
847        // rejecting files whose poles are intended on-axis. 1e-8 of the radial
848        // extent snaps that noise to an EXACT pole (radius 0, so every
849        // downstream `radius <= EPS` branch takes the degenerate path):
850        // corpus vendor noise measures up to ~2e-9 relative, and the
851        // 1e-6-relative nudge the fuzzy-pole test rejects stays two decades
852        // above the snap.
853        let measured_radius = radial_vector.length();
854        let pole_tolerance = EPS.max(1e-8 * radial_extent);
855        let radius = if measured_radius <= pole_tolerance {
856            0.0
857        } else {
858            measured_radius
859        };
860        let (x_axis, y_axis) = if radius <= EPS {
861            (Vec3::default(), Vec3::default())
862        } else {
863            // A control point is either ON the axis — the degenerate pole row
864            // above, whose rotation is a fixed point — or clearly off it. In
865            // between the revolution carries a fuzzy PINCH where a pole was
866            // meant to be, which downstream code (analytic recognition, pair
867            // classification, the imprint) reads as an ordinary tiny circle:
868            // refuse it here instead. This refusal used to fall out of the
869            // parallel-tangent guard below, whose ABSOLUTE floor made it
870            // "radius <= 1e-3" — which on a metre-unit STEP file rejected every
871            // healthy 1.524e-4 cylinder in the part. Relative to the
872            // generatrix's own radial extent it catches the fuzzy pole at any
873            // model scale and lets real geometry through at any size.
874            if radius <= NEAR_AXIS_RELATIVE_TOLERANCE * radial_extent {
875                return Err(format!(
876                    "makeRevolution: generatrix control point {column} sits {radius:e} from the \
877                     axis, neither on it nor clear of it against the generatrix's {radial_extent:e} \
878                     radial extent (a fuzzy pole)"
879                ));
880            }
881            let x_axis = radial_vector.scale(1.0 / radius);
882            (x_axis, axis.cross(x_axis))
883        };
884        rows[0][column] = Vec4::from_point(point, weight);
885        let mut start_point = point;
886        let mut start_tangent = y_axis.scale(radius);
887        let mut angle = 0.0;
888        for arc in 1..=arc_count {
889            angle += arc_angle;
890            let end_point = if radius <= EPS {
891                point
892            } else {
893                axis_origin
894                    .add(x_axis.scale(radius * angle.cos()))
895                    .add(y_axis.scale(radius * angle.sin()))
896            };
897            let end_tangent = if radius <= EPS {
898                Vec3::default()
899            } else {
900                x_axis
901                    .scale(-radius * angle.sin())
902                    .add(y_axis.scale(radius * angle.cos()))
903            };
904            let row = 2 * (arc - 1);
905            if radius <= EPS {
906                rows[row + 1][column] = Vec4::from_point(point, weight * middle_weight);
907            } else {
908                let cross = start_tangent.cross(end_tangent);
909                let denominator = cross.length_squared();
910                // Both tangents have length `radius`, so `denominator` is
911                // radius^4 * sin^2(arc_angle) and an ABSOLUTE floor here reads
912                // as "radius <= 1e-3", conflating a small part with a
913                // degenerate sweep. Parallel tangents are purely an ANGULAR
914                // condition, so scale the floor by the tangent lengths: the
915                // test becomes sin^2(arc_angle) <= EPS, exactly what make_arc
916                // applies to its already-unit tangents. Near-axis control
917                // points are the near-axis check's business, above.
918                let tangent_scale = start_tangent.length_squared() * end_tangent.length_squared();
919                if denominator <= EPS * tangent_scale {
920                    return Err("makeRevolution: arc tangents are parallel".into());
921                }
922                let distance =
923                    end_point.sub(start_point).cross(end_tangent).dot(cross) / denominator;
924                let middle = start_point.add(start_tangent.scale(distance));
925                rows[row + 1][column] = Vec4::from_point(middle, weight * middle_weight);
926            }
927            rows[row + 2][column] = Vec4::from_point(end_point, weight);
928            start_point = end_point;
929            start_tangent = end_tangent;
930        }
931    }
932    NurbsSurface::new(
933        2,
934        generatrix.degree,
935        knots_u,
936        generatrix.knots.clone(),
937        rows,
938    )
939}
940
941pub fn make_cylinder_surface(
942    base: Vec3,
943    axis_direction: Vec3,
944    radius: f64,
945    height: f64,
946) -> Result<NurbsSurface, String> {
947    if radius <= EPS || height <= EPS {
948        return Err("makeCylinder: radius and height must be positive".into());
949    }
950    let axis = axis_direction.normalized()?;
951    let x_axis = axis.perpendicular()?;
952    let start = base.add(x_axis.scale(radius));
953    let end = start.add(axis.scale(height));
954    let generatrix = crate::make_line(start, end)?;
955    make_revolution(base, axis, &generatrix, std::f64::consts::TAU)
956}
957
958pub fn make_cone_surface(
959    base: Vec3,
960    axis_direction: Vec3,
961    radius_bottom: f64,
962    radius_top: f64,
963    height: f64,
964) -> Result<NurbsSurface, String> {
965    if radius_bottom <= EPS || radius_top < 0.0 || height <= EPS {
966        return Err("makeCone: invalid radius or height".into());
967    }
968    let axis = axis_direction.normalized()?;
969    let x_axis = axis.perpendicular()?;
970    let start = base.add(x_axis.scale(radius_bottom));
971    let end = base.add(axis.scale(height)).add(x_axis.scale(radius_top));
972    let generatrix = crate::make_line(start, end)?;
973    make_revolution(base, axis, &generatrix, std::f64::consts::TAU)
974}
975
976pub fn make_sphere_surface(
977    center: Vec3,
978    radius: f64,
979    polar_axis: Vec3,
980) -> Result<NurbsSurface, String> {
981    make_sphere_surface_framed(center, radius, polar_axis, None)
982}
983
984/// A sphere with BOTH halves of its frame chosen: the polar axis (where the two
985/// degenerate poles sit) and the SEAM direction (where the `u = 0` meridian
986/// runs). `seam_direction` is Gram-Schmidt'd against the axis; `None` — and a
987/// seam parallel to the axis, which names no meridian — falls back to
988/// [`Vec3::perpendicular`], i.e. exactly what [`make_sphere_surface`] builds.
989///
990/// A caller that KNOWS where the sphere will be trimmed uses this to park the
991/// poles and the seam inside the region the trim removes, so the surviving face
992/// carries neither. `feature_pipeline::features::tube` does that for its joint
993/// balls: a seam that survives into the result is an edge bordering ONE face,
994/// which is a real edge in the topology and a visible line on the model.
995pub fn make_sphere_surface_framed(
996    center: Vec3,
997    radius: f64,
998    polar_axis: Vec3,
999    seam_direction: Option<Vec3>,
1000) -> Result<NurbsSurface, String> {
1001    if radius <= EPS {
1002        return Err("makeSphere: radius must be positive".into());
1003    }
1004    let axis = polar_axis.normalized()?;
1005    let x_axis = match seam_direction {
1006        Some(seam) => {
1007            let planar = seam.sub(axis.scale(seam.dot(axis)));
1008            match planar.normalized() {
1009                Ok(unit) if planar.length() > EPS => unit,
1010                _ => axis.perpendicular()?,
1011            }
1012        }
1013        None => axis.perpendicular()?,
1014    };
1015    let meridian = crate::make_arc(
1016        center,
1017        x_axis,
1018        axis,
1019        radius,
1020        -std::f64::consts::FRAC_PI_2,
1021        std::f64::consts::FRAC_PI_2,
1022    )?;
1023    make_revolution(center, axis, &meridian, std::f64::consts::TAU)
1024}
1025
1026pub fn make_torus_surface(
1027    center: Vec3,
1028    axis_direction: Vec3,
1029    major_radius: f64,
1030    minor_radius: f64,
1031) -> Result<NurbsSurface, String> {
1032    if minor_radius <= EPS || major_radius <= minor_radius {
1033        return Err("makeTorus: requires positive minorRadius < majorRadius".into());
1034    }
1035    let axis = axis_direction.normalized()?;
1036    let x_axis = axis.perpendicular()?;
1037    let tube_center = center.add(x_axis.scale(major_radius));
1038    let tube = crate::make_arc(
1039        tube_center,
1040        x_axis,
1041        axis,
1042        minor_radius,
1043        0.0,
1044        std::f64::consts::TAU,
1045    )?;
1046    make_revolution(center, axis, &tube, std::f64::consts::TAU)
1047}
1048
1049fn binomial(n: usize, k: usize) -> f64 {
1050    if k > n {
1051        return 0.0;
1052    }
1053    let k = k.min(n - k);
1054    (1..=k).fold(1.0, |value, index| {
1055        value * (n - k + index) as f64 / index as f64
1056    })
1057}
1058
1059// BREP private tests: ecf255325b772bff
1060
1061// BREP private tests: 6c04b017586ca0d0
1062
1063/// Diagnostic "carrier preview" patch (§3.15 applied to display): re-express
1064/// the surface over an INFLATED domain so an inspector can show where the
1065/// carrier continues beyond the face's trim. Open directions inflate about
1066/// the domain centre by `inflate` (a factor; 1 = unchanged), evaluated
1067/// through `evaluate_extended` — exact linear extension for affine carriers,
1068/// ruled/tangent extension generally. CLOSED (periodic) directions keep the
1069/// stored full period instead of inflating — wrapping further would overlap
1070/// the surface onto itself. A torus (closed both ways) returns unchanged.
1071///
1072/// Affine carriers rebuild EXACTLY from four extended corners; everything
1073/// else is a preview-quality degree-3 interpolation over a 33-sample grid.
1074pub fn carrier_preview_patch(surface: &NurbsSurface, inflate: f64) -> Result<NurbsSurface, String> {
1075    let inflate = if inflate.is_finite() {
1076        inflate.clamp(1.0, 16.0)
1077    } else {
1078        2.0
1079    };
1080    let (closed_u, closed_v) = surface.closed_directions()?;
1081    let [u0, u1] = surface.domain_u()?;
1082    let [v0, v1] = surface.domain_v()?;
1083    let stretch = |d0: f64, d1: f64, closed: bool| -> (f64, f64) {
1084        if closed || inflate <= 1.0 {
1085            (d0, d1)
1086        } else {
1087            let centre = 0.5 * (d0 + d1);
1088            let half = 0.5 * (d1 - d0) * inflate;
1089            (centre - half, centre + half)
1090        }
1091    };
1092    let (nu0, nu1) = stretch(u0, u1, closed_u);
1093    let (nv0, nv1) = stretch(v0, v1, closed_v);
1094    if (nu0, nu1) == (u0, u1) && (nv0, nv1) == (v0, v1) {
1095        return Ok(surface.clone());
1096    }
1097    if surface.is_affine()? {
1098        // Exact: the linear extension of an affine patch is the same affine
1099        // map over the larger rectangle.
1100        let corners = [
1101            surface.evaluate_extended(nu0, nv0)?,
1102            surface.evaluate_extended(nu1, nv0)?,
1103            surface.evaluate_extended(nu0, nv1)?,
1104            surface.evaluate_extended(nu1, nv1)?,
1105        ];
1106        return NurbsSurface::new(
1107            1,
1108            1,
1109            vec![nu0, nu0, nu1, nu1],
1110            vec![nv0, nv0, nv1, nv1],
1111            vec![
1112                vec![
1113                    Vec4::from_point(corners[0], 1.0),
1114                    Vec4::from_point(corners[2], 1.0),
1115                ],
1116                vec![
1117                    Vec4::from_point(corners[1], 1.0),
1118                    Vec4::from_point(corners[3], 1.0),
1119                ],
1120            ],
1121        );
1122    }
1123    const SAMPLES: usize = 32;
1124    let u_params: Vec<f64> = (0..=SAMPLES)
1125        .map(|index| nu0 + (nu1 - nu0) * index as f64 / SAMPLES as f64)
1126        .collect();
1127    let v_params: Vec<f64> = (0..=SAMPLES)
1128        .map(|index| nv0 + (nv1 - nv0) * index as f64 / SAMPLES as f64)
1129        .collect();
1130    // Two-pass grid interpolation: fit each v-column, then fit the resulting
1131    // control rows across u, sharing knots per pass (identical parameters).
1132    let mut column_curves = Vec::with_capacity(u_params.len());
1133    for &u in &u_params {
1134        let column: Vec<Vec3> = v_params
1135            .iter()
1136            .map(|&v| surface.evaluate_extended(u, v))
1137            .collect::<Result<_, _>>()?;
1138        column_curves.push(crate::interpolate_curve(
1139            &column,
1140            3,
1141            &normalized(&v_params),
1142        )?);
1143    }
1144    let v_knots = column_curves[0].knots.clone();
1145    let control_rows = column_curves[0].control_points.len();
1146    let mut grid: Vec<Vec<Vec4>> = Vec::with_capacity(u_params.len());
1147    let mut u_knots = Vec::new();
1148    for row_index in 0..control_rows {
1149        let row: Vec<Vec3> = column_curves
1150            .iter()
1151            .map(|curve| curve.control_points[row_index].point())
1152            .collect::<Result<_, _>>()?;
1153        let fitted = crate::interpolate_curve(&row, 3, &normalized(&u_params))?;
1154        u_knots = fitted.knots.clone();
1155        for (u_index, control) in fitted.control_points.iter().enumerate() {
1156            if grid.len() <= u_index {
1157                grid.push(Vec::new());
1158            }
1159            grid[u_index].push(*control);
1160        }
1161    }
1162    // Re-scale the normalized fit knots back onto the inflated domains so the
1163    // preview's parameter space lines up with the original surface's.
1164    let rescale = |knots: &[f64], d0: f64, d1: f64| -> Vec<f64> {
1165        knots.iter().map(|k| d0 + (d1 - d0) * k).collect()
1166    };
1167    NurbsSurface::new(
1168        3,
1169        3,
1170        rescale(&u_knots, nu0, nu1),
1171        rescale(&v_knots, nv0, nv1),
1172        grid,
1173    )
1174}
1175
1176fn normalized(parameters: &[f64]) -> Vec<f64> {
1177    let first = parameters[0];
1178    let last = parameters[parameters.len() - 1];
1179    parameters
1180        .iter()
1181        .map(|p| (p - first) / (last - first))
1182        .collect()
1183}
1184
1185// BREP private tests: 6cb24462fcd160ba