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