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    /// Whether this is a genuinely affine degree-1 by degree-1 patch.
611    ///
612    /// A tapered bilinear patch is planar but not affine, so degree alone is
613    /// insufficient for exact inverse-parameter and integration shortcuts.
614    pub fn is_affine(&self) -> Result<bool, String> {
615        if self.degree_u != 1
616            || self.degree_v != 1
617            || self.control_points.len() != 2
618            || self.control_points[0].len() != 2
619            || self.control_points[1].len() != 2
620        {
621            return Ok(false);
622        }
623        let weight = self.control_points[0][0].w;
624        if [
625            self.control_points[0][1].w,
626            self.control_points[1][0].w,
627            self.control_points[1][1].w,
628        ]
629        .iter()
630        .any(|other| (*other - weight).abs() > 1e-12)
631        {
632            return Ok(false);
633        }
634        let p00 = self.control_points[0][0].point()?;
635        let p01 = self.control_points[0][1].point()?;
636        let p10 = self.control_points[1][0].point()?;
637        let p11 = self.control_points[1][1].point()?;
638        let gap = p11.sub(p10).sub(p01.sub(p00)).length();
639        Ok(gap <= 1e-9 * (1.0 + p00.length().max(p11.length())))
640    }
641
642    pub fn iso_curve_u(&self, u: f64) -> Result<NurbsCurve, String> {
643        self.ensure_valid()?;
644        let u = knot_clamp(&self.knots_u, self.degree_u, u);
645        let span = knot_find_span(&self.knots_u, self.degree_u, u);
646        let basis = if self.degree_u <= MAX_STACK_DEGREE {
647            let mut stack = [0.0f64; MAX_STACK_ORDER];
648            basis_functions_into(&self.knots_u, self.degree_u, span, u, &mut stack);
649            stack[..=self.degree_u].to_vec()
650        } else {
651            KnotVector::new(self.knots_u.clone(), self.degree_u)?.basis_functions(span, u)
652        };
653        let mut points = Vec::with_capacity(self.control_points[0].len());
654        for column in 0..self.control_points[0].len() {
655            let mut point = Vec4 {
656                x: 0.0,
657                y: 0.0,
658                z: 0.0,
659                w: 0.0,
660            };
661            for (index, value) in basis.iter().enumerate() {
662                point = point
663                    .add(self.control_points[span - self.degree_u + index][column].scale(*value));
664            }
665            points.push(point);
666        }
667        NurbsCurve::new(self.degree_v, self.knots_v.clone(), points)
668    }
669
670    pub fn iso_curve_v(&self, v: f64) -> Result<NurbsCurve, String> {
671        self.ensure_valid()?;
672        let v = knot_clamp(&self.knots_v, self.degree_v, v);
673        let span = knot_find_span(&self.knots_v, self.degree_v, v);
674        let basis = if self.degree_v <= MAX_STACK_DEGREE {
675            let mut stack = [0.0f64; MAX_STACK_ORDER];
676            basis_functions_into(&self.knots_v, self.degree_v, span, v, &mut stack);
677            stack[..=self.degree_v].to_vec()
678        } else {
679            KnotVector::new(self.knots_v.clone(), self.degree_v)?.basis_functions(span, v)
680        };
681        let mut points = Vec::with_capacity(self.control_points.len());
682        for row in &self.control_points {
683            let mut point = Vec4 {
684                x: 0.0,
685                y: 0.0,
686                z: 0.0,
687                w: 0.0,
688            };
689            for (index, value) in basis.iter().enumerate() {
690                point = point.add(row[span - self.degree_v + index].scale(*value));
691            }
692            points.push(point);
693        }
694        NurbsCurve::new(self.degree_u, self.knots_u.clone(), points)
695    }
696}
697
698pub fn make_plane(
699    origin: Vec3,
700    u_direction: Vec3,
701    v_direction: Vec3,
702    u_max: f64,
703    v_max: f64,
704) -> Result<NurbsSurface, String> {
705    if u_max <= EPS || v_max <= EPS {
706        return Err("makePlane: parameter extents must be positive".into());
707    }
708    let p10 = origin.add(u_direction.scale(u_max));
709    let p01 = origin.add(v_direction.scale(v_max));
710    let p11 = p10.add(v_direction.scale(v_max));
711    NurbsSurface::new(
712        1,
713        1,
714        vec![0.0, 0.0, u_max, u_max],
715        vec![0.0, 0.0, v_max, v_max],
716        vec![
717            vec![Vec4::from_point(origin, 1.0), Vec4::from_point(p01, 1.0)],
718            vec![Vec4::from_point(p10, 1.0), Vec4::from_point(p11, 1.0)],
719        ],
720    )
721}
722
723pub fn make_extrusion(profile: &NurbsCurve, direction: Vec3) -> Result<NurbsSurface, String> {
724    let rows = profile
725        .control_points
726        .iter()
727        .map(|point| {
728            vec![
729                *point,
730                Vec4 {
731                    x: point.x + point.w * direction.x,
732                    y: point.y + point.w * direction.y,
733                    z: point.z + point.w * direction.z,
734                    w: point.w,
735                },
736            ]
737        })
738        .collect();
739    NurbsSurface::new(
740        profile.degree,
741        1,
742        profile.knots.clone(),
743        vec![0.0, 0.0, 1.0, 1.0],
744        rows,
745    )
746}
747
748pub fn make_revolution(
749    axis_point: Vec3,
750    axis_direction: Vec3,
751    generatrix: &NurbsCurve,
752    theta: f64,
753) -> Result<NurbsSurface, String> {
754    if theta <= EPS || theta > std::f64::consts::TAU + EPS {
755        return Err("makeRevolution: theta must be in (0, 2*PI]".into());
756    }
757    let theta = theta.min(std::f64::consts::TAU);
758    let axis = axis_direction.normalized()?;
759    let arc_count = ((theta / std::f64::consts::FRAC_PI_2 - EPS).ceil() as usize).clamp(1, 4);
760    let arc_angle = theta / arc_count as f64;
761    let middle_weight = (arc_angle / 2.0).cos();
762    let mut knots_u = vec![0.0, 0.0, 0.0];
763    for index in 1..arc_count {
764        let knot = index as f64 / arc_count as f64;
765        knots_u.extend([knot, knot]);
766    }
767    knots_u.extend([1.0, 1.0, 1.0]);
768    let mut rows = vec![
769        vec![
770            Vec4 {
771                x: 0.0,
772                y: 0.0,
773                z: 0.0,
774                w: 1.0,
775            };
776            generatrix.control_points.len()
777        ];
778        2 * arc_count + 1
779    ];
780
781    // How far the generatrix reaches from the axis — the scale every "is this
782    // control point on the axis?" question below is measured against.
783    let mut radial_extent: f64 = 0.0;
784    for control_point in &generatrix.control_points {
785        let point = control_point.point()?;
786        let offset = point.sub(axis_point);
787        radial_extent = radial_extent.max(offset.sub(axis.scale(offset.dot(axis))).length());
788    }
789
790    for (column, control_point) in generatrix.control_points.iter().enumerate() {
791        let point = control_point.point()?;
792        let weight = control_point.w;
793        let axis_origin = axis_point.add(axis.scale(point.sub(axis_point).dot(axis)));
794        let radial_vector = point.sub(axis_origin);
795        // On-axis classification must be RELATIVE like the fuzzy band below:
796        // the absolute EPS floor alone broke when the importer's mm conversion
797        // scaled vendor pole noise (1.5e-13 m -> 1.5e-10 mm) past it, hard-
798        // rejecting files whose poles are intended on-axis. 1e-8 of the radial
799        // extent snaps that noise to an EXACT pole (radius 0, so every
800        // downstream `radius <= EPS` branch takes the degenerate path):
801        // corpus vendor noise measures up to ~2e-9 relative, and the
802        // 1e-6-relative nudge the fuzzy-pole test rejects stays two decades
803        // above the snap.
804        let measured_radius = radial_vector.length();
805        let pole_tolerance = EPS.max(1e-8 * radial_extent);
806        let radius = if measured_radius <= pole_tolerance {
807            0.0
808        } else {
809            measured_radius
810        };
811        let (x_axis, y_axis) = if radius <= EPS {
812            (Vec3::default(), Vec3::default())
813        } else {
814            // A control point is either ON the axis — the degenerate pole row
815            // above, whose rotation is a fixed point — or clearly off it. In
816            // between the revolution carries a fuzzy PINCH where a pole was
817            // meant to be, which downstream code (analytic recognition, pair
818            // classification, the imprint) reads as an ordinary tiny circle:
819            // refuse it here instead. This refusal used to fall out of the
820            // parallel-tangent guard below, whose ABSOLUTE floor made it
821            // "radius <= 1e-3" — which on a metre-unit STEP file rejected every
822            // healthy 1.524e-4 cylinder in the part. Relative to the
823            // generatrix's own radial extent it catches the fuzzy pole at any
824            // model scale and lets real geometry through at any size.
825            if radius <= NEAR_AXIS_RELATIVE_TOLERANCE * radial_extent {
826                return Err(format!(
827                    "makeRevolution: generatrix control point {column} sits {radius:e} from the \
828                     axis, neither on it nor clear of it against the generatrix's {radial_extent:e} \
829                     radial extent (a fuzzy pole)"
830                ));
831            }
832            let x_axis = radial_vector.scale(1.0 / radius);
833            (x_axis, axis.cross(x_axis))
834        };
835        rows[0][column] = Vec4::from_point(point, weight);
836        let mut start_point = point;
837        let mut start_tangent = y_axis.scale(radius);
838        let mut angle = 0.0;
839        for arc in 1..=arc_count {
840            angle += arc_angle;
841            let end_point = if radius <= EPS {
842                point
843            } else {
844                axis_origin
845                    .add(x_axis.scale(radius * angle.cos()))
846                    .add(y_axis.scale(radius * angle.sin()))
847            };
848            let end_tangent = if radius <= EPS {
849                Vec3::default()
850            } else {
851                x_axis
852                    .scale(-radius * angle.sin())
853                    .add(y_axis.scale(radius * angle.cos()))
854            };
855            let row = 2 * (arc - 1);
856            if radius <= EPS {
857                rows[row + 1][column] = Vec4::from_point(point, weight * middle_weight);
858            } else {
859                let cross = start_tangent.cross(end_tangent);
860                let denominator = cross.length_squared();
861                // Both tangents have length `radius`, so `denominator` is
862                // radius^4 * sin^2(arc_angle) and an ABSOLUTE floor here reads
863                // as "radius <= 1e-3", conflating a small part with a
864                // degenerate sweep. Parallel tangents are purely an ANGULAR
865                // condition, so scale the floor by the tangent lengths: the
866                // test becomes sin^2(arc_angle) <= EPS, exactly what make_arc
867                // applies to its already-unit tangents. Near-axis control
868                // points are the near-axis check's business, above.
869                let tangent_scale = start_tangent.length_squared() * end_tangent.length_squared();
870                if denominator <= EPS * tangent_scale {
871                    return Err("makeRevolution: arc tangents are parallel".into());
872                }
873                let distance =
874                    end_point.sub(start_point).cross(end_tangent).dot(cross) / denominator;
875                let middle = start_point.add(start_tangent.scale(distance));
876                rows[row + 1][column] = Vec4::from_point(middle, weight * middle_weight);
877            }
878            rows[row + 2][column] = Vec4::from_point(end_point, weight);
879            start_point = end_point;
880            start_tangent = end_tangent;
881        }
882    }
883    NurbsSurface::new(
884        2,
885        generatrix.degree,
886        knots_u,
887        generatrix.knots.clone(),
888        rows,
889    )
890}
891
892pub fn make_cylinder_surface(
893    base: Vec3,
894    axis_direction: Vec3,
895    radius: f64,
896    height: f64,
897) -> Result<NurbsSurface, String> {
898    if radius <= EPS || height <= EPS {
899        return Err("makeCylinder: radius and height must be positive".into());
900    }
901    let axis = axis_direction.normalized()?;
902    let x_axis = axis.perpendicular()?;
903    let start = base.add(x_axis.scale(radius));
904    let end = start.add(axis.scale(height));
905    let generatrix = crate::make_line(start, end)?;
906    make_revolution(base, axis, &generatrix, std::f64::consts::TAU)
907}
908
909pub fn make_cone_surface(
910    base: Vec3,
911    axis_direction: Vec3,
912    radius_bottom: f64,
913    radius_top: f64,
914    height: f64,
915) -> Result<NurbsSurface, String> {
916    if radius_bottom <= EPS || radius_top < 0.0 || height <= EPS {
917        return Err("makeCone: invalid radius or height".into());
918    }
919    let axis = axis_direction.normalized()?;
920    let x_axis = axis.perpendicular()?;
921    let start = base.add(x_axis.scale(radius_bottom));
922    let end = base.add(axis.scale(height)).add(x_axis.scale(radius_top));
923    let generatrix = crate::make_line(start, end)?;
924    make_revolution(base, axis, &generatrix, std::f64::consts::TAU)
925}
926
927pub fn make_sphere_surface(
928    center: Vec3,
929    radius: f64,
930    polar_axis: Vec3,
931) -> Result<NurbsSurface, String> {
932    if radius <= EPS {
933        return Err("makeSphere: radius must be positive".into());
934    }
935    let axis = polar_axis.normalized()?;
936    let x_axis = axis.perpendicular()?;
937    let meridian = crate::make_arc(
938        center,
939        x_axis,
940        axis,
941        radius,
942        -std::f64::consts::FRAC_PI_2,
943        std::f64::consts::FRAC_PI_2,
944    )?;
945    make_revolution(center, axis, &meridian, std::f64::consts::TAU)
946}
947
948pub fn make_torus_surface(
949    center: Vec3,
950    axis_direction: Vec3,
951    major_radius: f64,
952    minor_radius: f64,
953) -> Result<NurbsSurface, String> {
954    if minor_radius <= EPS || major_radius <= minor_radius {
955        return Err("makeTorus: requires positive minorRadius < majorRadius".into());
956    }
957    let axis = axis_direction.normalized()?;
958    let x_axis = axis.perpendicular()?;
959    let tube_center = center.add(x_axis.scale(major_radius));
960    let tube = crate::make_arc(
961        tube_center,
962        x_axis,
963        axis,
964        minor_radius,
965        0.0,
966        std::f64::consts::TAU,
967    )?;
968    make_revolution(center, axis, &tube, std::f64::consts::TAU)
969}
970
971fn binomial(n: usize, k: usize) -> f64 {
972    if k > n {
973        return 0.0;
974    }
975    let k = k.min(n - k);
976    (1..=k).fold(1.0, |value, index| {
977        value * (n - k + index) as f64 / index as f64
978    })
979}
980
981#[cfg(test)]
982mod extension_tests {
983    use crate::{make_cylinder_surface, make_line, make_plane, Vec3};
984
985    #[test]
986    fn curve_extension_wraps_closed_and_extends_open() {
987        let line = make_line(Vec3::default(), Vec3::new(10.0, 0.0, 0.0)).unwrap();
988        let [start, end] = line.domain().unwrap();
989        let beyond = line.evaluate_extended(end + (end - start) * 0.5).unwrap();
990        assert!(beyond.sub(Vec3::new(15.0, 0.0, 0.0)).length() < 1e-9);
991        let circle = crate::make_arc(
992            Vec3::default(),
993            Vec3::new(1.0, 0.0, 0.0),
994            Vec3::new(0.0, 1.0, 0.0),
995            2.0,
996            0.0,
997            std::f64::consts::TAU,
998        )
999        .unwrap();
1000        let [c0, c1] = circle.domain().unwrap();
1001        let wrapped = circle.evaluate_extended(c1 + (c1 - c0) * 0.25).unwrap();
1002        let reference = circle.evaluate(c0 + (c1 - c0) * 0.25).unwrap();
1003        assert!(wrapped.sub(reference).length() < 1e-9);
1004    }
1005
1006    #[test]
1007    fn surface_extension_is_exact_on_planes_and_wraps_closed_directions() {
1008        let plane = make_plane(
1009            Vec3::default(),
1010            Vec3::new(1.0, 0.0, 0.0),
1011            Vec3::new(0.0, 1.0, 0.0),
1012            4.0,
1013            3.0,
1014        )
1015        .unwrap();
1016        let [u0, u1] = plane.domain_u().unwrap();
1017        let [v0, v1] = plane.domain_v().unwrap();
1018        // Corner extension of an affine patch continues the plane exactly.
1019        let du = (u1 - u0) * 0.5;
1020        let dv = (v1 - v0) * 0.25;
1021        let extended = plane.evaluate_extended(u1 + du, v1 + dv).unwrap();
1022        assert!(extended.sub(Vec3::new(6.0, 3.75, 0.0)).length() < 1e-9);
1023        let cylinder =
1024            make_cylinder_surface(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
1025        let [cu0, cu1] = cylinder.domain_u().unwrap();
1026        let [cv0, cv1] = cylinder.domain_v().unwrap();
1027        let wrapped = cylinder
1028            .evaluate_extended(cu1 + (cu1 - cu0) * 0.125, (cv0 + cv1) * 0.5)
1029            .unwrap();
1030        let reference = cylinder
1031            .evaluate(cu0 + (cu1 - cu0) * 0.125, (cv0 + cv1) * 0.5)
1032            .unwrap();
1033        assert!(wrapped.sub(reference).length() < 1e-9);
1034        // Open v direction extends along the ruling.
1035        let beyond = cylinder
1036            .evaluate_extended(cu0, cv1 + (cv1 - cv0) * 0.2)
1037            .unwrap();
1038        let top = cylinder.evaluate(cu0, cv1).unwrap();
1039        assert!((beyond.z - (top.z + 1.0)).abs() < 1e-9);
1040        assert!((beyond.x - top.x).abs() < 1e-9 && (beyond.y - top.y).abs() < 1e-9);
1041    }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047
1048    fn plane() -> NurbsSurface {
1049        NurbsSurface::new(
1050            1,
1051            1,
1052            vec![0.0, 0.0, 4.0, 4.0],
1053            vec![-2.0, -2.0, 3.0, 3.0],
1054            vec![
1055                vec![
1056                    Vec4::from_point(Vec3::new(1.0, 2.0, 3.0), 1.0),
1057                    Vec4::from_point(Vec3::new(1.0, 2.0, 13.0), 1.0),
1058                ],
1059                vec![
1060                    Vec4::from_point(Vec3::new(9.0, 2.0, 3.0), 1.0),
1061                    Vec4::from_point(Vec3::new(9.0, 2.0, 13.0), 1.0),
1062                ],
1063            ],
1064        )
1065        .unwrap()
1066    }
1067
1068    #[test]
1069    fn affine_surface_evaluation_and_partials_are_exact() {
1070        let surface = plane();
1071        let point = surface.evaluate(1.0, 0.5).unwrap();
1072        assert!(point.sub(Vec3::new(3.0, 2.0, 8.0)).length() < 1e-13);
1073        let derivatives = surface.derivatives(1.0, 0.5, 2).unwrap();
1074        assert!(derivatives[1][0].sub(Vec3::new(2.0, 0.0, 0.0)).length() < 1e-13);
1075        assert!(derivatives[0][1].sub(Vec3::new(0.0, 0.0, 2.0)).length() < 1e-13);
1076        assert!(
1077            surface
1078                .normal(1.0, 0.5)
1079                .unwrap()
1080                .sub(Vec3::new(0.0, -1.0, 0.0))
1081                .length()
1082                < 1e-13
1083        );
1084    }
1085
1086    /// `make_revolution`'s parallel-tangent guard used to be an ABSOLUTE floor on
1087    /// `radius^4 * sin^2(arc_angle)`, so every generatrix running within
1088    /// EPS^(1/4) = 1e-3 of the axis was rejected as degenerate — a metre-unit
1089    /// STEP file with a 1.524e-4 hole could not be imported at all. The guard is
1090    /// angular, so the surface must build (and be exact) at ANY radius.
1091    #[test]
1092    fn revolution_builds_at_every_radius_scale() {
1093        for exponent in 0..12 {
1094            let radius = 10f64.powi(-exponent);
1095            let surface = make_cylinder_surface(
1096                Vec3::new(0.0, 0.0, 0.0),
1097                Vec3::new(0.0, 0.0, 1.0),
1098                radius,
1099                radius,
1100            )
1101            .unwrap_or_else(|error| panic!("radius {radius:e}: {error}"));
1102            let [u0, u1] = surface.domain_u().unwrap();
1103            let [v0, v1] = surface.domain_v().unwrap();
1104            for step in 0..=16 {
1105                let u = u0 + (u1 - u0) * step as f64 / 16.0;
1106                let point = surface.evaluate(u, 0.5 * (v0 + v1)).unwrap();
1107                let deviation = (point.x.hypot(point.y) - radius).abs();
1108                assert!(
1109                    deviation <= 1e-12 * radius,
1110                    "radius {radius:e} u {u}: off the cylinder by {deviation:e}"
1111                );
1112            }
1113        }
1114    }
1115
1116    /// The guard is still a guard: a sweep so small that the two arc tangents are
1117    /// numerically parallel is rejected rather than divided through.
1118    #[test]
1119    fn revolution_rejects_a_degenerate_sweep() {
1120        let generatrix =
1121            crate::make_line(Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 1.0)).unwrap();
1122        let error = make_revolution(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), &generatrix, 1e-9)
1123            .unwrap_err();
1124        assert!(error.contains("parallel"), "unexpected error: {error}");
1125    }
1126
1127    /// The OTHER half of the split: a generatrix endpoint that is neither on the
1128    /// axis nor clear of it — the fuzzy pole a numeric offset or fit leaves
1129    /// behind — is still refused, and the refusal is now measured against the
1130    /// generatrix's own radial extent, so it holds at any model scale. (It used
1131    /// to fall out of the parallel-tangent guard's absolute floor, which is why
1132    /// it only worked for parts sized around 1.)
1133    #[test]
1134    fn revolution_rejects_a_fuzzy_pole_at_any_scale() {
1135        for scale in [1e-3, 1.0, 1e3] {
1136            let generatrix = crate::make_arc(
1137                Vec3::default(),
1138                Vec3::new(1.0, 0.0, 0.0),
1139                Vec3::new(0.0, 0.0, 1.0),
1140                scale,
1141                0.0,
1142                std::f64::consts::FRAC_PI_2,
1143            )
1144            .unwrap();
1145            // Nudge the pole control point off the axis by 1e-6 of the extent —
1146            // far too little to be a real circle, far too much to be a pole.
1147            let mut control_points = generatrix.control_points.clone();
1148            let last = control_points.len() - 1;
1149            control_points[last].x += 1e-6 * scale * control_points[last].w;
1150            let fuzzy = NurbsCurve::new(2, generatrix.knots.clone(), control_points).unwrap();
1151            let error = make_revolution(
1152                Vec3::default(),
1153                Vec3::new(0.0, 0.0, 1.0),
1154                &fuzzy,
1155                std::f64::consts::TAU,
1156            )
1157            .unwrap_err();
1158            assert!(error.contains("fuzzy pole"), "scale {scale:e}: {error}");
1159
1160            // The unnudged arc, whose pole is exact, still builds.
1161            make_revolution(
1162                Vec3::default(),
1163                Vec3::new(0.0, 0.0, 1.0),
1164                &generatrix,
1165                std::f64::consts::TAU,
1166            )
1167            .unwrap_or_else(|error| panic!("scale {scale:e}: exact pole rejected: {error}"));
1168        }
1169    }
1170}
1171
1172/// Diagnostic "carrier preview" patch (§3.15 applied to display): re-express
1173/// the surface over an INFLATED domain so an inspector can show where the
1174/// carrier continues beyond the face's trim. Open directions inflate about
1175/// the domain centre by `inflate` (a factor; 1 = unchanged), evaluated
1176/// through `evaluate_extended` — exact linear extension for affine carriers,
1177/// ruled/tangent extension generally. CLOSED (periodic) directions keep the
1178/// stored full period instead of inflating — wrapping further would overlap
1179/// the surface onto itself. A torus (closed both ways) returns unchanged.
1180///
1181/// Affine carriers rebuild EXACTLY from four extended corners; everything
1182/// else is a preview-quality degree-3 interpolation over a 33-sample grid.
1183pub fn carrier_preview_patch(surface: &NurbsSurface, inflate: f64) -> Result<NurbsSurface, String> {
1184    let inflate = if inflate.is_finite() {
1185        inflate.clamp(1.0, 16.0)
1186    } else {
1187        2.0
1188    };
1189    let (closed_u, closed_v) = surface.closed_directions()?;
1190    let [u0, u1] = surface.domain_u()?;
1191    let [v0, v1] = surface.domain_v()?;
1192    let stretch = |d0: f64, d1: f64, closed: bool| -> (f64, f64) {
1193        if closed || inflate <= 1.0 {
1194            (d0, d1)
1195        } else {
1196            let centre = 0.5 * (d0 + d1);
1197            let half = 0.5 * (d1 - d0) * inflate;
1198            (centre - half, centre + half)
1199        }
1200    };
1201    let (nu0, nu1) = stretch(u0, u1, closed_u);
1202    let (nv0, nv1) = stretch(v0, v1, closed_v);
1203    if (nu0, nu1) == (u0, u1) && (nv0, nv1) == (v0, v1) {
1204        return Ok(surface.clone());
1205    }
1206    if surface.is_affine()? {
1207        // Exact: the linear extension of an affine patch is the same affine
1208        // map over the larger rectangle.
1209        let corners = [
1210            surface.evaluate_extended(nu0, nv0)?,
1211            surface.evaluate_extended(nu1, nv0)?,
1212            surface.evaluate_extended(nu0, nv1)?,
1213            surface.evaluate_extended(nu1, nv1)?,
1214        ];
1215        return NurbsSurface::new(
1216            1,
1217            1,
1218            vec![nu0, nu0, nu1, nu1],
1219            vec![nv0, nv0, nv1, nv1],
1220            vec![
1221                vec![
1222                    Vec4::from_point(corners[0], 1.0),
1223                    Vec4::from_point(corners[2], 1.0),
1224                ],
1225                vec![
1226                    Vec4::from_point(corners[1], 1.0),
1227                    Vec4::from_point(corners[3], 1.0),
1228                ],
1229            ],
1230        );
1231    }
1232    const SAMPLES: usize = 32;
1233    let u_params: Vec<f64> = (0..=SAMPLES)
1234        .map(|index| nu0 + (nu1 - nu0) * index as f64 / SAMPLES as f64)
1235        .collect();
1236    let v_params: Vec<f64> = (0..=SAMPLES)
1237        .map(|index| nv0 + (nv1 - nv0) * index as f64 / SAMPLES as f64)
1238        .collect();
1239    // Two-pass grid interpolation: fit each v-column, then fit the resulting
1240    // control rows across u, sharing knots per pass (identical parameters).
1241    let mut column_curves = Vec::with_capacity(u_params.len());
1242    for &u in &u_params {
1243        let column: Vec<Vec3> = v_params
1244            .iter()
1245            .map(|&v| surface.evaluate_extended(u, v))
1246            .collect::<Result<_, _>>()?;
1247        column_curves.push(crate::interpolate_curve(
1248            &column,
1249            3,
1250            &normalized(&v_params),
1251        )?);
1252    }
1253    let v_knots = column_curves[0].knots.clone();
1254    let control_rows = column_curves[0].control_points.len();
1255    let mut grid: Vec<Vec<Vec4>> = Vec::with_capacity(u_params.len());
1256    let mut u_knots = Vec::new();
1257    for row_index in 0..control_rows {
1258        let row: Vec<Vec3> = column_curves
1259            .iter()
1260            .map(|curve| curve.control_points[row_index].point())
1261            .collect::<Result<_, _>>()?;
1262        let fitted = crate::interpolate_curve(&row, 3, &normalized(&u_params))?;
1263        u_knots = fitted.knots.clone();
1264        for (u_index, control) in fitted.control_points.iter().enumerate() {
1265            if grid.len() <= u_index {
1266                grid.push(Vec::new());
1267            }
1268            grid[u_index].push(*control);
1269        }
1270    }
1271    // Re-scale the normalized fit knots back onto the inflated domains so the
1272    // preview's parameter space lines up with the original surface's.
1273    let rescale = |knots: &[f64], d0: f64, d1: f64| -> Vec<f64> {
1274        knots.iter().map(|k| d0 + (d1 - d0) * k).collect()
1275    };
1276    NurbsSurface::new(
1277        3,
1278        3,
1279        rescale(&u_knots, nu0, nu1),
1280        rescale(&v_knots, nv0, nv1),
1281        grid,
1282    )
1283}
1284
1285fn normalized(parameters: &[f64]) -> Vec<f64> {
1286    let first = parameters[0];
1287    let last = parameters[parameters.len() - 1];
1288    parameters
1289        .iter()
1290        .map(|p| (p - first) / (last - first))
1291        .collect()
1292}
1293
1294#[cfg(test)]
1295mod carrier_preview_tests {
1296    use super::*;
1297    use crate::{make_cylinder_surface, make_torus_surface, Vec3};
1298
1299    #[test]
1300    fn plane_preview_extends_exactly() {
1301        let plane = make_plane(
1302            Vec3::new(1.0, 2.0, 3.0),
1303            Vec3::new(1.0, 0.0, 0.0),
1304            Vec3::new(0.0, 1.0, 0.0),
1305            4.0,
1306            2.0,
1307        )
1308        .unwrap();
1309        let preview = carrier_preview_patch(&plane, 3.0).unwrap();
1310        let [u0, u1] = preview.domain_u().unwrap();
1311        let [v0, v1] = preview.domain_v().unwrap();
1312        assert!((u1 - u0 - 12.0).abs() < 1e-12, "u span tripled");
1313        assert!((v1 - v0 - 6.0).abs() < 1e-12, "v span tripled");
1314        // Every preview point continues the SAME affine map exactly.
1315        for (u, v) in [
1316            (u0, v0),
1317            (u1, v1),
1318            (0.5 * (u0 + u1), v0),
1319            (u0, 0.5 * (v0 + v1)),
1320        ] {
1321            let point = preview.evaluate(u, v).unwrap();
1322            let expected = Vec3::new(1.0 + u, 2.0 + v, 3.0);
1323            assert!(
1324                point.sub(expected).length() < 1e-9,
1325                "{point:?} at ({u},{v})"
1326            );
1327        }
1328    }
1329
1330    #[test]
1331    fn cylinder_preview_keeps_the_period_and_extends_the_axis() {
1332        let cylinder =
1333            make_cylinder_surface(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 4.0).unwrap();
1334        let [u0, u1] = cylinder.domain_u().unwrap();
1335        let preview = carrier_preview_patch(&cylinder, 2.0).unwrap();
1336        let [pu0, pu1] = preview.domain_u().unwrap();
1337        let [pv0, pv1] = preview.domain_v().unwrap();
1338        // Periodic u keeps the stored full period; open v doubles.
1339        assert!(
1340            (pu0 - u0).abs() < 1e-12 && (pu1 - u1).abs() < 1e-12,
1341            "u untouched"
1342        );
1343        let [v0, v1] = cylinder.domain_v().unwrap();
1344        assert!((pv1 - pv0) > 1.9 * (v1 - v0), "v inflated");
1345        // Points beyond the original v-range ride the exact axis extension:
1346        // still radius 2 about the z-axis (the ruled extension is the
1347        // cylinder itself).
1348        for sample in 0..=16 {
1349            let u = pu0 + (pu1 - pu0) * sample as f64 / 16.0;
1350            for v in [pv0, pv1] {
1351                let point = preview.evaluate(u, v).unwrap();
1352                let radial = (point.x * point.x + point.y * point.y).sqrt();
1353                assert!((radial - 2.0).abs() < 2e-3, "radius at ({u},{v}): {radial}");
1354            }
1355        }
1356    }
1357
1358    #[test]
1359    fn torus_preview_is_unchanged() {
1360        let torus =
1361            make_torus_surface(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 1.0).unwrap();
1362        let preview = carrier_preview_patch(&torus, 4.0).unwrap();
1363        assert_eq!(preview.domain_u().unwrap(), torus.domain_u().unwrap());
1364        assert_eq!(preview.domain_v().unwrap(), torus.domain_v().unwrap());
1365    }
1366}