Skip to main content

brep_kernel/geometry/
curve.rs

1use crate::Vec3;
2use serde::{Deserialize, Serialize};
3
4const EPS: f64 = 1e-12;
5
6/// Knot IDENTITY tolerance (parameter-space): two knot parameters within this
7/// absolute band are treated as the SAME knot for snapping — insert_knot,
8/// `NurbsCurve::split`, monotonicity and clamp checks.  This is the single
9/// source for the "absolute 1e-9 knot tolerance" that `split` enforces and
10/// that imprint's trim guards mirror.  Distinct in PURPOSE (not just value)
11/// from [`KNOT_DEDUP_EPS`]; the two are deliberately NOT unified.
12pub const KNOT_IDENTITY_TOL: f64 = 1e-9;
13
14/// Numerical knot DEDUP epsilon (parameter-space): the tighter floor used when
15/// *counting distinct* knots (SSI/CSI `interior_knot_count`) or comparing whole
16/// knot vectors for exact reconstruction (analytic surface recognition).  This
17/// answers "are these two knot floats the same value?", NOT "are these the same
18/// knot for snapping?" — so it stays at 1e-12 and must NOT be unified with the
19/// looser [`KNOT_IDENTITY_TOL`].
20pub const KNOT_DEDUP_EPS: f64 = 1e-12;
21
22#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
23pub struct Vec4 {
24    pub x: f64,
25    pub y: f64,
26    pub z: f64,
27    pub w: f64,
28}
29
30impl Vec4 {
31    pub fn from_point(point: Vec3, weight: f64) -> Self {
32        Self {
33            x: point.x * weight,
34            y: point.y * weight,
35            z: point.z * weight,
36            w: weight,
37        }
38    }
39
40    pub(crate) fn add(self, rhs: Self) -> Self {
41        Self {
42            x: self.x + rhs.x,
43            y: self.y + rhs.y,
44            z: self.z + rhs.z,
45            w: self.w + rhs.w,
46        }
47    }
48
49    pub(crate) fn scale(self, factor: f64) -> Self {
50        Self {
51            x: self.x * factor,
52            y: self.y * factor,
53            z: self.z * factor,
54            w: self.w * factor,
55        }
56    }
57
58    pub(crate) fn point(self) -> Result<Vec3, String> {
59        if self.w.abs() <= EPS {
60            return Err("cannot project a homogeneous point with zero weight".into());
61        }
62        Ok(Vec3::new(self.x / self.w, self.y / self.w, self.z / self.w))
63    }
64}
65
66/// Largest degree served by the allocation-free stack-array basis engine.
67/// Higher degrees (rare: only externally imported geometry) fall back to the
68/// heap-allocating `KnotVector` path.
69pub(crate) const MAX_STACK_DEGREE: usize = 7;
70pub(crate) const MAX_STACK_ORDER: usize = MAX_STACK_DEGREE + 1;
71
72/// Validation shared by `KnotVector::new` and the lazily-validated
73/// curve/surface types. Must stay the single source of truth so cached
74/// validation and eager validation reject exactly the same inputs.
75pub(crate) fn validate_knots(knots: &[f64], degree: usize) -> Result<(), String> {
76    if degree < 1 {
77        return Err("KnotVector: degree must be >= 1".into());
78    }
79    if knots.len() < 2 * (degree + 1) {
80        return Err(format!(
81            "KnotVector: need at least {} knots for degree {}, got {}",
82            2 * (degree + 1),
83            degree,
84            knots.len()
85        ));
86    }
87    if knots.iter().any(|value| !value.is_finite()) {
88        return Err("KnotVector: knots must be finite".into());
89    }
90    if knots
91        .windows(2)
92        .any(|pair| pair[1] < pair[0] - KNOT_IDENTITY_TOL)
93    {
94        return Err("KnotVector: knots must be non-decreasing".into());
95    }
96    let first = knots[0];
97    let last = knots[knots.len() - 1];
98    for index in 0..=degree {
99        if (knots[index] - first).abs() > KNOT_IDENTITY_TOL {
100            return Err("KnotVector: expected clamped start".into());
101        }
102        if (knots[knots.len() - 1 - index] - last).abs() > KNOT_IDENTITY_TOL {
103            return Err("KnotVector: expected clamped end".into());
104        }
105    }
106    if last - first <= KNOT_IDENTITY_TOL {
107        return Err("KnotVector: degenerate parameter range".into());
108    }
109    Ok(())
110}
111
112pub(crate) fn knot_domain(knots: &[f64], degree: usize) -> [f64; 2] {
113    [knots[degree], knots[knots.len() - 1 - degree]]
114}
115
116pub(crate) fn knot_clamp(knots: &[f64], degree: usize, parameter: f64) -> f64 {
117    let [start, end] = knot_domain(knots, degree);
118    parameter.clamp(start, end)
119}
120
121pub(crate) fn knot_find_span(knots: &[f64], degree: usize, parameter: f64) -> usize {
122    let parameter = knot_clamp(knots, degree, parameter);
123    let n = knots.len() - degree - 2;
124    if parameter >= knots[n + 1] {
125        // At/after the domain end.  A properly clamped vector has the end knot
126        // at multiplicity exactly `degree + 1`, so span `n` is the last
127        // non-empty interval.  An over-clamped end (a vendor exporter can emit
128        // multiplicity > degree + 1, e.g. two coincident knot VALUES) leaves
129        // span `n` pointing at a zero-width interval whose basis functions are
130        // all zero.  Walk back to the last interval that actually has width.
131        let mut span = n;
132        while span > degree && knots[span] >= knots[span + 1] {
133            span -= 1;
134        }
135        return span;
136    }
137    if parameter <= knots[degree] {
138        // At/before the domain start — the mirror of the case above.  For a
139        // properly clamped start (multiplicity degree + 1) this returns
140        // `degree`; for an over-clamped start it advances past the extra
141        // coincident knots to the first non-empty interval so evaluation never
142        // lands on a zero-width span (which would yield an all-zero,
143        // zero-weight homogeneous point).
144        let mut span = degree;
145        while span < n && knots[span + 1] <= parameter {
146            span += 1;
147        }
148        return span;
149    }
150    let mut low = degree;
151    let mut high = n + 1;
152    let mut middle = (low + high) / 2;
153    while parameter < knots[middle] || parameter >= knots[middle + 1] {
154        if parameter < knots[middle] {
155            high = middle;
156        } else {
157            low = middle;
158        }
159        middle = (low + high) / 2;
160    }
161    middle
162}
163
164/// Cox–de Boor basis functions into a caller-provided stack array.
165/// Requires `degree <= MAX_STACK_DEGREE`; entries `0..=degree` are written.
166pub(crate) fn basis_functions_into(
167    knots: &[f64],
168    degree: usize,
169    span: usize,
170    parameter: f64,
171    basis: &mut [f64; MAX_STACK_ORDER],
172) {
173    debug_assert!(degree <= MAX_STACK_DEGREE);
174    let mut left = [0.0f64; MAX_STACK_ORDER];
175    let mut right = [0.0f64; MAX_STACK_ORDER];
176    basis[0] = 1.0;
177    for j in 1..=degree {
178        left[j] = parameter - knots[span + 1 - j];
179        right[j] = knots[span + j] - parameter;
180        let mut saved = 0.0;
181        for r in 0..j {
182            let denominator = right[r + 1] + left[j - r];
183            let temporary = if denominator.abs() <= EPS {
184                0.0
185            } else {
186                basis[r] / denominator
187            };
188            basis[r] = saved + right[r + 1] * temporary;
189            saved = left[j - r] * temporary;
190        }
191        basis[j] = saved;
192    }
193}
194
195/// Basis derivatives (The NURBS Book A2.3) into caller-provided stack rows.
196/// Writes rows `0..=min(derivative_count, degree)` of `out`; rows above the
197/// degree keep whatever the caller initialized them to (callers zero-fill,
198/// matching the heap implementation's zero rows). Requires
199/// `degree <= MAX_STACK_DEGREE` and `out.len() > derivative_count`.
200pub(crate) fn basis_derivatives_into(
201    knots: &[f64],
202    degree: usize,
203    span: usize,
204    parameter: f64,
205    derivative_count: usize,
206    out: &mut [[f64; MAX_STACK_ORDER]],
207) {
208    debug_assert!(degree <= MAX_STACK_DEGREE);
209    debug_assert!(out.len() > derivative_count);
210    let p = degree;
211    let n = derivative_count.min(p);
212    let mut ndu = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
213    let mut left = [0.0f64; MAX_STACK_ORDER];
214    let mut right = [0.0f64; MAX_STACK_ORDER];
215    ndu[0][0] = 1.0;
216
217    for j in 1..=p {
218        left[j] = parameter - knots[span + 1 - j];
219        right[j] = knots[span + j] - parameter;
220        let mut saved = 0.0;
221        for r in 0..j {
222            ndu[j][r] = right[r + 1] + left[j - r];
223            let temporary = if ndu[j][r].abs() <= EPS {
224                0.0
225            } else {
226                ndu[r][j - 1] / ndu[j][r]
227            };
228            ndu[r][j] = saved + right[r + 1] * temporary;
229            saved = left[j - r] * temporary;
230        }
231        ndu[j][j] = saved;
232    }
233
234    for j in 0..=p {
235        out[0][j] = ndu[j][p];
236    }
237    let mut a = [[0.0f64; MAX_STACK_ORDER]; 2];
238    for r in 0..=p {
239        let mut s1 = 0;
240        let mut s2 = 1;
241        a[0][0] = 1.0;
242        for k in 1..=n {
243            a[s2] = [0.0; MAX_STACK_ORDER];
244            let mut value = 0.0;
245            let rk = r as isize - k as isize;
246            let pk = p - k;
247            if r >= k {
248                let denominator = ndu[pk + 1][rk as usize];
249                a[s2][0] = a[s1][0] / denominator;
250                value = a[s2][0] * ndu[rk as usize][pk];
251            }
252            let j1 = if rk >= -1 { 1 } else { (-rk) as usize };
253            let j2 = if r <= pk + 1 { k - 1 } else { p - r };
254            if j1 <= j2 {
255                for j in j1..=j2 {
256                    let index = (rk + j as isize) as usize;
257                    a[s2][j] = (a[s1][j] - a[s1][j - 1]) / ndu[pk + 1][index];
258                    value += a[s2][j] * ndu[index][pk];
259                }
260            }
261            if r <= pk {
262                a[s2][k] = -a[s1][k - 1] / ndu[pk + 1][r];
263                value += a[s2][k] * ndu[r][pk];
264            }
265            out[k][r] = value;
266            std::mem::swap(&mut s1, &mut s2);
267        }
268    }
269    let mut factor = p as f64;
270    for k in 1..=n {
271        for value in out[k][..=p].iter_mut() {
272            *value *= factor;
273        }
274        factor *= (p - k) as f64;
275    }
276}
277
278#[derive(Clone, Debug, Deserialize, Serialize)]
279pub struct KnotVector {
280    pub knots: Vec<f64>,
281    pub degree: usize,
282}
283
284impl KnotVector {
285    pub fn new(knots: Vec<f64>, degree: usize) -> Result<Self, String> {
286        validate_knots(&knots, degree)?;
287        Ok(Self { knots, degree })
288    }
289
290    pub fn control_point_count(&self) -> usize {
291        self.knots.len() - self.degree - 1
292    }
293
294    pub fn domain(&self) -> [f64; 2] {
295        [
296            self.knots[self.degree],
297            self.knots[self.knots.len() - 1 - self.degree],
298        ]
299    }
300
301    pub fn clamp_param(&self, parameter: f64) -> f64 {
302        knot_clamp(&self.knots, self.degree, parameter)
303    }
304
305    pub fn find_span(&self, parameter: f64) -> usize {
306        knot_find_span(&self.knots, self.degree, parameter)
307    }
308
309    pub fn basis_functions(&self, span: usize, parameter: f64) -> Vec<f64> {
310        if self.degree <= MAX_STACK_DEGREE {
311            let mut basis = [0.0f64; MAX_STACK_ORDER];
312            basis_functions_into(&self.knots, self.degree, span, parameter, &mut basis);
313            return basis[..=self.degree].to_vec();
314        }
315        let mut basis = vec![0.0; self.degree + 1];
316        let mut left = vec![0.0; self.degree + 1];
317        let mut right = vec![0.0; self.degree + 1];
318        basis[0] = 1.0;
319        for j in 1..=self.degree {
320            left[j] = parameter - self.knots[span + 1 - j];
321            right[j] = self.knots[span + j] - parameter;
322            let mut saved = 0.0;
323            for r in 0..j {
324                let denominator = right[r + 1] + left[j - r];
325                let temporary = if denominator.abs() <= EPS {
326                    0.0
327                } else {
328                    basis[r] / denominator
329                };
330                basis[r] = saved + right[r + 1] * temporary;
331                saved = left[j - r] * temporary;
332            }
333            basis[j] = saved;
334        }
335        basis
336    }
337
338    pub fn basis_derivatives(
339        &self,
340        span: usize,
341        parameter: f64,
342        derivative_count: usize,
343    ) -> Vec<Vec<f64>> {
344        if self.degree <= MAX_STACK_DEGREE && derivative_count <= MAX_STACK_DEGREE {
345            let mut rows = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
346            basis_derivatives_into(
347                &self.knots,
348                self.degree,
349                span,
350                parameter,
351                derivative_count,
352                &mut rows[..=derivative_count],
353            );
354            return rows[..=derivative_count]
355                .iter()
356                .map(|row| row[..=self.degree].to_vec())
357                .collect();
358        }
359        let p = self.degree;
360        let n = derivative_count.min(p);
361        let mut ndu = vec![vec![0.0; p + 1]; p + 1];
362        let mut left = vec![0.0; p + 1];
363        let mut right = vec![0.0; p + 1];
364        ndu[0][0] = 1.0;
365
366        for j in 1..=p {
367            left[j] = parameter - self.knots[span + 1 - j];
368            right[j] = self.knots[span + j] - parameter;
369            let mut saved = 0.0;
370            for r in 0..j {
371                ndu[j][r] = right[r + 1] + left[j - r];
372                let temporary = if ndu[j][r].abs() <= EPS {
373                    0.0
374                } else {
375                    ndu[r][j - 1] / ndu[j][r]
376                };
377                ndu[r][j] = saved + right[r + 1] * temporary;
378                saved = left[j - r] * temporary;
379            }
380            ndu[j][j] = saved;
381        }
382
383        let mut derivatives = vec![vec![0.0; p + 1]; derivative_count + 1];
384        for j in 0..=p {
385            derivatives[0][j] = ndu[j][p];
386        }
387        let mut a = vec![vec![0.0; p + 1]; 2];
388        for r in 0..=p {
389            let mut s1 = 0;
390            let mut s2 = 1;
391            a[0][0] = 1.0;
392            for k in 1..=n {
393                a[s2].fill(0.0);
394                let mut value = 0.0;
395                let rk = r as isize - k as isize;
396                let pk = p - k;
397                if r >= k {
398                    let denominator = ndu[pk + 1][rk as usize];
399                    a[s2][0] = a[s1][0] / denominator;
400                    value = a[s2][0] * ndu[rk as usize][pk];
401                }
402                let j1 = if rk >= -1 { 1 } else { (-rk) as usize };
403                let j2 = if r <= pk + 1 { k - 1 } else { p - r };
404                if j1 <= j2 {
405                    for j in j1..=j2 {
406                        let index = (rk + j as isize) as usize;
407                        a[s2][j] = (a[s1][j] - a[s1][j - 1]) / ndu[pk + 1][index];
408                        value += a[s2][j] * ndu[index][pk];
409                    }
410                }
411                if r <= pk {
412                    a[s2][k] = -a[s1][k - 1] / ndu[pk + 1][r];
413                    value += a[s2][k] * ndu[r][pk];
414                }
415                derivatives[k][r] = value;
416                std::mem::swap(&mut s1, &mut s2);
417            }
418        }
419        let mut factor = p as f64;
420        for (k, row) in derivatives.iter_mut().enumerate().take(n + 1).skip(1) {
421            for value in row {
422                *value *= factor;
423            }
424            factor *= (p - k) as f64;
425        }
426        derivatives
427    }
428}
429
430#[derive(Clone, Debug, Deserialize, Serialize)]
431pub struct NurbsCurve {
432    pub degree: usize,
433    pub knots: Vec<f64>,
434    pub control_points: Vec<Vec4>,
435    /// One-time validation cache. Deserialized curves (which bypass `new`)
436    /// run the full `new`-equivalent checks on first geometric use instead of
437    /// re-validating the knot vector on every evaluation.
438    #[serde(skip, default)]
439    validated: std::cell::Cell<bool>,
440}
441
442impl NurbsCurve {
443    pub fn new(degree: usize, knots: Vec<f64>, control_points: Vec<Vec4>) -> Result<Self, String> {
444        let curve = Self {
445            degree,
446            knots,
447            control_points,
448            validated: std::cell::Cell::new(false),
449        };
450        curve.ensure_valid()?;
451        Ok(curve)
452    }
453
454    /// The full construction-time checks, run at most once per instance.
455    fn ensure_valid(&self) -> Result<(), String> {
456        if self.validated.get() {
457            return Ok(());
458        }
459        validate_knots(&self.knots, self.degree)?;
460        let expected = self.knots.len() - self.degree - 1;
461        if self.control_points.len() != expected {
462            return Err(format!(
463                "NurbsCurve: knot vector implies {} control points, got {}",
464                expected,
465                self.control_points.len()
466            ));
467        }
468        if self.control_points.iter().any(|point| {
469            point.w <= EPS
470                || ![point.x, point.y, point.z, point.w]
471                    .iter()
472                    .all(|value| value.is_finite())
473        }) {
474            return Err("NurbsCurve: control points must be finite with positive weights".into());
475        }
476        self.validated.set(true);
477        Ok(())
478    }
479
480    fn knot_vector(&self) -> Result<KnotVector, String> {
481        KnotVector::new(self.knots.clone(), self.degree)
482    }
483
484    pub fn domain(&self) -> Result<[f64; 2], String> {
485        self.ensure_valid()?;
486        Ok(knot_domain(&self.knots, self.degree))
487    }
488
489    pub fn evaluate_homogeneous(&self, parameter: f64) -> Result<Vec4, String> {
490        self.ensure_valid()?;
491        let span = knot_find_span(&self.knots, self.degree, parameter);
492        let parameter = knot_clamp(&self.knots, self.degree, parameter);
493        let mut point = Vec4 {
494            x: 0.0,
495            y: 0.0,
496            z: 0.0,
497            w: 0.0,
498        };
499        if self.degree <= MAX_STACK_DEGREE {
500            let mut basis = [0.0f64; MAX_STACK_ORDER];
501            basis_functions_into(&self.knots, self.degree, span, parameter, &mut basis);
502            for (index, value) in basis[..=self.degree].iter().enumerate() {
503                point = point.add(self.control_points[span - self.degree + index].scale(*value));
504            }
505        } else {
506            let knot_vector = self.knot_vector()?;
507            let basis = knot_vector.basis_functions(span, parameter);
508            for (index, value) in basis.iter().enumerate() {
509                point = point.add(self.control_points[span - self.degree + index].scale(*value));
510            }
511        }
512        Ok(point)
513    }
514
515    pub fn evaluate(&self, parameter: f64) -> Result<Vec3, String> {
516        self.evaluate_homogeneous(parameter)?.point()
517    }
518
519    /// Value beyond the domain (Golovanov §2.15): every curve must answer
520    /// out-of-domain queries because intersection and projection
521    /// algorithms probe there.  Closed curves wrap the parameter
522    /// cyclically; open curves extend linearly along the end tangent.
523    pub fn evaluate_extended(&self, parameter: f64) -> Result<Vec3, String> {
524        Ok(self.derivatives_extended(parameter, 0)?[0])
525    }
526
527    /// Derivatives beyond the domain (§2.15).  The open-end extension is
528    /// linear: the first derivative is the boundary tangent and higher
529    /// derivatives vanish.
530    pub fn derivatives_extended(
531        &self,
532        parameter: f64,
533        derivative_count: usize,
534    ) -> Result<Vec<Vec3>, String> {
535        let [start, end] = self.domain()?;
536        if parameter >= start && parameter <= end {
537            return self.derivatives(parameter, derivative_count);
538        }
539        let period = end - start;
540        if period > 0.0 {
541            let closed =
542                self.evaluate(start)?.sub(self.evaluate(end)?).length() <= 1e-9 * (1.0 + period);
543            if closed {
544                let wrapped = start + (parameter - start).rem_euclid(period);
545                return self.derivatives(wrapped, derivative_count);
546            }
547        }
548        let boundary = if parameter < start { start } else { end };
549        let base = self.derivatives(boundary, derivative_count.max(1))?;
550        let mut result = Vec::with_capacity(derivative_count + 1);
551        result.push(base[0].add(base[1].scale(parameter - boundary)));
552        if derivative_count >= 1 {
553            result.push(base[1]);
554        }
555        for _ in 2..=derivative_count {
556            result.push(Vec3::default());
557        }
558        Ok(result)
559    }
560
561    pub fn derivatives(
562        &self,
563        parameter: f64,
564        derivative_count: usize,
565    ) -> Result<Vec<Vec3>, String> {
566        self.ensure_valid()?;
567        let parameter = knot_clamp(&self.knots, self.degree, parameter);
568        let span = knot_find_span(&self.knots, self.degree, parameter);
569        let calculated_count = derivative_count.min(self.degree);
570        let mut homogeneous: Vec<Vec4> = Vec::with_capacity(calculated_count + 1);
571        if self.degree <= MAX_STACK_DEGREE {
572            let mut rows = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
573            basis_derivatives_into(
574                &self.knots,
575                self.degree,
576                span,
577                parameter,
578                calculated_count,
579                &mut rows[..=calculated_count],
580            );
581            for row in rows.iter().take(calculated_count + 1) {
582                let mut point = Vec4 {
583                    x: 0.0,
584                    y: 0.0,
585                    z: 0.0,
586                    w: 0.0,
587                };
588                for (index, value) in row.iter().enumerate().take(self.degree + 1) {
589                    point =
590                        point.add(self.control_points[span - self.degree + index].scale(*value));
591                }
592                homogeneous.push(point);
593            }
594        } else {
595            let knot_vector = self.knot_vector()?;
596            let basis = knot_vector.basis_derivatives(span, parameter, calculated_count);
597            for row in basis.iter().take(calculated_count + 1) {
598                let mut point = Vec4 {
599                    x: 0.0,
600                    y: 0.0,
601                    z: 0.0,
602                    w: 0.0,
603                };
604                for (index, value) in row.iter().enumerate().take(self.degree + 1) {
605                    point =
606                        point.add(self.control_points[span - self.degree + index].scale(*value));
607                }
608                homogeneous.push(point);
609            }
610        }
611
612        let mut result: Vec<Vec3> = Vec::with_capacity(derivative_count + 1);
613        for k in 0..=calculated_count {
614            let mut value = Vec3::new(homogeneous[k].x, homogeneous[k].y, homogeneous[k].z);
615            for i in 1..=k {
616                value = value.sub(result[k - i].scale(binomial(k, i) * homogeneous[i].w));
617            }
618            result.push(value.scale(1.0 / homogeneous[0].w));
619        }
620        result.resize(derivative_count + 1, Vec3::default());
621        Ok(result)
622    }
623
624    /// Allocation-free twin of [`Self::derivatives`] for the hot
625    /// `derivative_count <= 2` path.
626    ///
627    /// Byte-for-byte faithful copy of the arithmetic in [`Self::derivatives`]:
628    /// identical basis evaluation, identical summation order, and the identical
629    /// rational de-homogenization recurrence. Only the storage differs — a
630    /// fixed-size `[Vec4; 3]` / `[Vec3; 3]` on the stack instead of the heap
631    /// `Vec`s. Entries beyond `derivative_count.min(degree)` stay
632    /// `Vec3::default()`, exactly as the heap version's trailing `resize` leaves
633    /// them.
634    pub(crate) fn derivatives_small(
635        &self,
636        parameter: f64,
637        derivative_count: usize,
638    ) -> Result<[Vec3; 3], String> {
639        debug_assert!(derivative_count <= 2);
640        self.ensure_valid()?;
641        let parameter = knot_clamp(&self.knots, self.degree, parameter);
642        let span = knot_find_span(&self.knots, self.degree, parameter);
643        let calculated_count = derivative_count.min(self.degree);
644        let zero = Vec4 {
645            x: 0.0,
646            y: 0.0,
647            z: 0.0,
648            w: 0.0,
649        };
650        let mut homogeneous = [zero; 3];
651        if self.degree <= MAX_STACK_DEGREE {
652            let mut rows = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
653            basis_derivatives_into(
654                &self.knots,
655                self.degree,
656                span,
657                parameter,
658                calculated_count,
659                &mut rows[..=calculated_count],
660            );
661            for (k, row) in rows.iter().take(calculated_count + 1).enumerate() {
662                let mut point = zero;
663                for (index, value) in row.iter().enumerate().take(self.degree + 1) {
664                    point =
665                        point.add(self.control_points[span - self.degree + index].scale(*value));
666                }
667                homogeneous[k] = point;
668            }
669        } else {
670            let knot_vector = self.knot_vector()?;
671            let basis = knot_vector.basis_derivatives(span, parameter, calculated_count);
672            for (k, row) in basis.iter().take(calculated_count + 1).enumerate() {
673                let mut point = zero;
674                for (index, value) in row.iter().enumerate().take(self.degree + 1) {
675                    point =
676                        point.add(self.control_points[span - self.degree + index].scale(*value));
677                }
678                homogeneous[k] = point;
679            }
680        }
681
682        let mut result = [Vec3::default(); 3];
683        for k in 0..=calculated_count {
684            let mut value = Vec3::new(homogeneous[k].x, homogeneous[k].y, homogeneous[k].z);
685            for i in 1..=k {
686                value = value.sub(result[k - i].scale(binomial(k, i) * homogeneous[i].w));
687            }
688            result[k] = value.scale(1.0 / homogeneous[0].w);
689        }
690        Ok(result)
691    }
692
693    /// Point and first derivative `(C, C')` with zero heap allocation.
694    /// Bit-identical to `derivatives(t, 1)` at indices `[0]` and `[1]`.
695    #[inline]
696    pub(crate) fn deriv1(&self, parameter: f64) -> Result<(Vec3, Vec3), String> {
697        let d = self.derivatives_small(parameter, 1)?;
698        Ok((d[0], d[1]))
699    }
700
701    pub fn reversed(&self) -> Result<Self, String> {
702        let start = self.knots[0];
703        let end = self.knots[self.knots.len() - 1];
704        let knots = self
705            .knots
706            .iter()
707            .rev()
708            .map(|knot| start + end - knot)
709            .collect();
710        let control_points = self.control_points.iter().rev().copied().collect();
711        Self::new(self.degree, knots, control_points)
712    }
713
714    pub fn insert_knot(&self, parameter: f64, requested: usize) -> Result<Self, String> {
715        let knot_vector = self.knot_vector()?;
716        let degree = self.degree;
717        let parameter = knot_vector.clamp_param(parameter);
718        // A parameter within knot tolerance of an existing knot must BE
719        // that knot: counting it as a multiplicity while find_span places
720        // it in the span below (parameter infinitesimally smaller) makes
721        // the Boehm index bookkeeping inconsistent and corrupts the net.
722        let parameter = self
723            .knots
724            .iter()
725            .copied()
726            .find(|knot| (knot - parameter).abs() <= KNOT_IDENTITY_TOL)
727            .unwrap_or(parameter);
728        let multiplicity = self
729            .knots
730            .iter()
731            .filter(|knot| (**knot - parameter).abs() <= KNOT_IDENTITY_TOL)
732            .count();
733        let insertion_count = requested.min(degree.saturating_sub(multiplicity));
734        if insertion_count == 0 {
735            return Ok(self.clone());
736        }
737        let span = knot_vector.find_span(parameter);
738        let last_control = self.control_points.len() - 1;
739        let mut knots = Vec::with_capacity(self.knots.len() + insertion_count);
740        knots.extend_from_slice(&self.knots[..=span]);
741        knots.extend(std::iter::repeat_n(parameter, insertion_count));
742        knots.extend_from_slice(&self.knots[span + 1..]);
743
744        let mut output = vec![
745            Vec4 {
746                x: 0.0,
747                y: 0.0,
748                z: 0.0,
749                w: 1.0,
750            };
751            last_control + 1 + insertion_count
752        ];
753        output[..=span - degree].copy_from_slice(&self.control_points[..=span - degree]);
754        for index in span - multiplicity..=last_control {
755            output[index + insertion_count] = self.control_points[index];
756        }
757        let mut affected = vec![
758            Vec4 {
759                x: 0.0,
760                y: 0.0,
761                z: 0.0,
762                w: 1.0,
763            };
764            degree + 1
765        ];
766        affected[..=degree - multiplicity]
767            .copy_from_slice(&self.control_points[span - degree..=span - multiplicity]);
768        let mut left = 0;
769        for insertion in 1..=insertion_count {
770            left = span - degree + insertion;
771            for index in 0..=degree - insertion - multiplicity {
772                let denominator = self.knots[index + span + 1] - self.knots[left + index];
773                let alpha = (parameter - self.knots[left + index]) / denominator;
774                affected[index] = affected[index + 1]
775                    .scale(alpha)
776                    .add(affected[index].scale(1.0 - alpha));
777            }
778            output[left] = affected[0];
779            output[span + insertion_count - insertion - multiplicity] =
780                affected[degree - insertion - multiplicity];
781        }
782        for index in left + 1..span - multiplicity {
783            output[index] = affected[index - left];
784        }
785        Self::new(degree, knots, output)
786    }
787
788    pub fn split(&self, parameter: f64) -> Result<(Self, Self), String> {
789        let [start, end] = self.domain()?;
790        if parameter <= start + KNOT_IDENTITY_TOL || parameter >= end - KNOT_IDENTITY_TOL {
791            return Err(format!(
792                "NurbsCurve.split: parameter {parameter} must be strictly inside domain [{start}, {end}]"
793            ));
794        }
795        // Snap onto a coincident knot so multiplicity and span agree (see
796        // insert_knot).
797        let parameter = self
798            .knots
799            .iter()
800            .copied()
801            .find(|knot| (knot - parameter).abs() <= KNOT_IDENTITY_TOL)
802            .unwrap_or(parameter);
803        let multiplicity = self
804            .knots
805            .iter()
806            .filter(|knot| (**knot - parameter).abs() <= KNOT_IDENTITY_TOL)
807            .count();
808        let refined = self.insert_knot(parameter, self.degree.saturating_sub(multiplicity))?;
809        let first = refined
810            .knots
811            .iter()
812            .position(|knot| (*knot - parameter).abs() <= KNOT_IDENTITY_TOL)
813            .ok_or_else(|| "NurbsCurve.split: inserted knot not found".to_string())?;
814        let mut left_knots = refined.knots[..first + self.degree].to_vec();
815        left_knots.push(parameter);
816        let left_points = refined.control_points[..first].to_vec();
817        let mut right_knots = vec![parameter; self.degree + 1];
818        right_knots.extend_from_slice(&refined.knots[first + self.degree..]);
819        let right_points = refined.control_points[first - 1..].to_vec();
820        Ok((
821            Self::new(self.degree, left_knots, left_points)?,
822            Self::new(self.degree, right_knots, right_points)?,
823        ))
824    }
825}
826
827pub fn make_line(start: Vec3, end: Vec3) -> Result<NurbsCurve, String> {
828    NurbsCurve::new(
829        1,
830        vec![0.0, 0.0, 1.0, 1.0],
831        vec![Vec4::from_point(start, 1.0), Vec4::from_point(end, 1.0)],
832    )
833}
834
835pub fn make_arc(
836    center: Vec3,
837    x_axis: Vec3,
838    y_axis: Vec3,
839    radius: f64,
840    start_angle: f64,
841    end_angle: f64,
842) -> Result<NurbsCurve, String> {
843    if radius <= EPS {
844        return Err("makeArc: radius must be positive".into());
845    }
846    let x_axis = x_axis.normalized()?;
847    let y_axis = y_axis.normalized()?;
848    if x_axis.dot(y_axis).abs() > 1e-9 {
849        return Err("makeArc: xAxis and yAxis must be orthogonal".into());
850    }
851    let mut theta = end_angle - start_angle;
852    if theta <= EPS {
853        return Err("makeArc: endAngle must exceed startAngle".into());
854    }
855    if theta > std::f64::consts::TAU + EPS {
856        return Err("makeArc: sweep exceeds full circle".into());
857    }
858    theta = theta.min(std::f64::consts::TAU);
859    let segment_count = ((theta / std::f64::consts::FRAC_PI_2 - EPS).ceil() as usize).clamp(1, 4);
860    let segment_angle = theta / segment_count as f64;
861    let middle_weight = (segment_angle / 2.0).cos();
862    let point_at = |angle: f64| {
863        center
864            .add(x_axis.scale(radius * angle.cos()))
865            .add(y_axis.scale(radius * angle.sin()))
866    };
867    let tangent_at = |angle: f64| x_axis.scale(-angle.sin()).add(y_axis.scale(angle.cos()));
868
869    let mut points = Vec::with_capacity(2 * segment_count + 1);
870    let mut angle = start_angle;
871    let mut first_point = point_at(angle);
872    let mut first_tangent = tangent_at(angle);
873    points.push(Vec4::from_point(first_point, 1.0));
874    for _ in 0..segment_count {
875        angle += segment_angle;
876        let end_point = point_at(angle);
877        let end_tangent = tangent_at(angle);
878        let cross = first_tangent.cross(end_tangent);
879        let denominator = cross.length_squared();
880        if denominator <= EPS {
881            return Err("makeArc: arc tangents are parallel".into());
882        }
883        let distance = end_point.sub(first_point).cross(end_tangent).dot(cross) / denominator;
884        let middle = first_point.add(first_tangent.scale(distance));
885        points.push(Vec4::from_point(middle, middle_weight));
886        points.push(Vec4::from_point(end_point, 1.0));
887        first_point = end_point;
888        first_tangent = end_tangent;
889    }
890    let mut knots = vec![0.0, 0.0, 0.0];
891    for index in 1..segment_count {
892        let knot = index as f64 / segment_count as f64;
893        knots.extend([knot, knot]);
894    }
895    knots.extend([1.0, 1.0, 1.0]);
896    NurbsCurve::new(2, knots, points)
897}
898
899pub fn make_circle(center: Vec3, normal: Vec3, radius: f64) -> Result<NurbsCurve, String> {
900    let normal = normal.normalized()?;
901    let x_axis = normal.perpendicular()?;
902    let y_axis = normal.cross(x_axis).normalized()?;
903    make_arc(center, x_axis, y_axis, radius, 0.0, std::f64::consts::TAU)
904}
905
906/// Build the exactly orthonormal local frame shared by the conic
907/// constructors.  The conic algebra below (implicit equation, tangent
908/// intersection, shoulder weight) is only exact in an orthonormal frame, so
909/// the in-plane hint is Gram-Schmidt-projected against the primary direction
910/// instead of trusted verbatim: callers only owe us non-parallel vectors.
911fn conic_frame(fn_name: &str, primary: Vec3, hint: Vec3) -> Result<(Vec3, Vec3), String> {
912    let x_axis = primary
913        .normalized()
914        .map_err(|_| format!("{fn_name}: primary axis must be non-zero"))?;
915    if hint.length() <= EPS {
916        return Err(format!("{fn_name}: in-plane direction must be non-zero"));
917    }
918    hint.sub(x_axis.scale(hint.dot(x_axis)))
919        .normalized()
920        .map(|y_axis| (x_axis, y_axis))
921        .map_err(|_| format!("{fn_name}: frame directions must not be parallel"))
922}
923
924/// Trim-range validation shared by the conic constructors.  The knot span IS
925/// the conic parameter range, so it must clear the knot identity band or the
926/// resulting curve would fail knot validation with an unhelpful message.
927fn conic_range(fn_name: &str, t0: f64, t1: f64) -> Result<(), String> {
928    if !t0.is_finite() || !t1.is_finite() {
929        return Err(format!("{fn_name}: parameter range must be finite"));
930    }
931    if t1 - t0 <= KNOT_IDENTITY_TOL {
932        return Err(format!("{fn_name}: t1 ({t1}) must exceed t0 ({t0})"));
933    }
934    Ok(())
935}
936
937/// Extreme trim parameters overflow the conic point formulas (cosh exceeds
938/// f64 near |t| ~ 710, focal·t² near |t| ~ 1e150); surface that as the
939/// constructor's own honest error instead of the generic NurbsCurve
940/// finiteness rejection.
941fn conic_points_finite(fn_name: &str, points: &[Vec4]) -> Result<(), String> {
942    if points.iter().any(|point| {
943        ![point.x, point.y, point.z, point.w]
944            .iter()
945            .all(|value| value.is_finite())
946    }) {
947        return Err(format!(
948            "{fn_name}: control points overflow f64 — parameter range too extreme"
949        ));
950    }
951    Ok(())
952}
953
954/// Exact parabola segment y² = 4·focal·x in the local frame (vertex at the
955/// origin, `axis` = +x, `latus_direction` = +y), parametrized the standard
956/// way P(t) = (focal·t², 2·focal·t) and trimmed to t ∈ [t0, t1].  A parabola
957/// segment is a plain quadratic polynomial in t (all weights 1), so a single
958/// degree-2 Bézier over the knot span [t0, t1] reproduces both the point set
959/// AND the parametrization exactly — no fitting, and evaluate(t) == P(t) for
960/// every t, not just at the ends.
961pub fn make_parabola(
962    vertex: Vec3,
963    axis: Vec3,
964    latus_direction: Vec3,
965    focal: f64,
966    t0: f64,
967    t1: f64,
968) -> Result<NurbsCurve, String> {
969    if !focal.is_finite() || focal <= EPS {
970        return Err("make_parabola: focal distance must be positive".into());
971    }
972    conic_range("make_parabola", t0, t1)?;
973    let (x_axis, y_axis) = conic_frame("make_parabola", axis, latus_direction)?;
974    let point_at = |t: f64| {
975        vertex
976            .add(x_axis.scale(focal * t * t))
977            .add(y_axis.scale(2.0 * focal * t))
978    };
979    // Bernstein middle point of the quadratic polynomial,
980    // P(t0) + (t1−t0)/2·P'(t0), collapses to local (focal·t0·t1,
981    // focal·(t0+t1)) — which is also the intersection of the two end
982    // tangents, as it must be for any parabola segment.
983    let middle = vertex
984        .add(x_axis.scale(focal * t0 * t1))
985        .add(y_axis.scale(focal * (t0 + t1)));
986    let control_points = vec![
987        Vec4::from_point(point_at(t0), 1.0),
988        Vec4::from_point(middle, 1.0),
989        Vec4::from_point(point_at(t1), 1.0),
990    ];
991    conic_points_finite("make_parabola", &control_points)?;
992    NurbsCurve::new(2, vec![t0, t0, t0, t1, t1, t1], control_points)
993}
994
995/// Exact arc of the hyperbola branch x²/a² − y²/b² = 1, x > 0 in the local
996/// frame (center at the origin, `major_axis` = +x, `minor_axis` = +y),
997/// parametrized P(t) = (a·cosh t, b·sinh t) and trimmed to t ∈ [t0, t1].
998/// A single rational quadratic Bézier is exact for any sweep on one branch:
999/// endpoints on the curve, middle control point at the intersection of the
1000/// end tangents, and middle weight cosh((t1−t0)/2) chosen so the shoulder
1001/// point lands back on the branch.  Only the point set is hyperbola-exact
1002/// away from the ends; the NURBS parameter coincides with the hyperbolic
1003/// parameter t exactly at t0, (t0+t1)/2 and t1.
1004pub fn make_hyperbola(
1005    center: Vec3,
1006    major_axis: Vec3,
1007    minor_axis: Vec3,
1008    a: f64,
1009    b: f64,
1010    t0: f64,
1011    t1: f64,
1012) -> Result<NurbsCurve, String> {
1013    if !a.is_finite() || a <= EPS {
1014        return Err("make_hyperbola: semi-axis a must be positive".into());
1015    }
1016    if !b.is_finite() || b <= EPS {
1017        return Err("make_hyperbola: semi-axis b must be positive".into());
1018    }
1019    conic_range("make_hyperbola", t0, t1)?;
1020    let (x_axis, y_axis) = conic_frame("make_hyperbola", major_axis, minor_axis)?;
1021    let point_at = |t: f64| {
1022        center
1023            .add(x_axis.scale(a * t.cosh()))
1024            .add(y_axis.scale(b * t.sinh()))
1025    };
1026    let mid = 0.5 * (t0 + t1);
1027    let middle_weight = (0.5 * (t1 - t0)).cosh();
1028    // In the scaled frame (x/a, y/b) the branch is the unit hyperbola and the
1029    // tangent at t is the line X·cosh t − Y·sinh t = 1; Cramer's rule on the
1030    // two end-tangent lines collapses their intersection to
1031    // P(mid)/cosh(half-sweep), so no explicit line-line solve is needed.
1032    // With this middle point, weight cosh(half-sweep) puts the shoulder point
1033    // (P0 + 2w·P1 + P2)/(2 + 2w) back on the branch at P(mid).
1034    let apex = center
1035        .add(x_axis.scale(a * mid.cosh() / middle_weight))
1036        .add(y_axis.scale(b * mid.sinh() / middle_weight));
1037    let control_points = vec![
1038        Vec4::from_point(point_at(t0), 1.0),
1039        Vec4::from_point(apex, middle_weight),
1040        Vec4::from_point(point_at(t1), 1.0),
1041    ];
1042    conic_points_finite("make_hyperbola", &control_points)?;
1043    NurbsCurve::new(2, vec![t0, t0, t0, t1, t1, t1], control_points)
1044}
1045
1046pub fn uniform_clamped_knots(
1047    control_point_count: usize,
1048    degree: usize,
1049) -> Result<Vec<f64>, String> {
1050    if control_point_count == 0 || control_point_count - 1 < degree {
1051        return Err("uniformClampedKnots: need at least degree+1 control points".into());
1052    }
1053    let n = control_point_count - 1;
1054    let mut knots = vec![0.0; degree + 1];
1055    let interior = n - degree;
1056    for index in 1..=interior {
1057        knots.push(index as f64 / (interior + 1) as f64);
1058    }
1059    knots.extend(std::iter::repeat_n(1.0, degree + 1));
1060    Ok(knots)
1061}
1062
1063fn binomial(n: usize, k: usize) -> f64 {
1064    if k > n {
1065        return 0.0;
1066    }
1067    let k = k.min(n - k);
1068    (1..=k).fold(1.0, |value, index| {
1069        value * (n - k + index) as f64 / index as f64
1070    })
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    use super::*;
1076
1077    fn close(a: Vec3, b: Vec3, tolerance: f64) {
1078        assert!(a.sub(b).length() <= tolerance, "{a:?} != {b:?}");
1079    }
1080
1081    #[test]
1082    fn line_evaluation_and_derivative_are_exact() {
1083        let curve = make_line(Vec3::new(1.0, 2.0, 3.0), Vec3::new(5.0, 8.0, 11.0)).unwrap();
1084        close(
1085            curve.evaluate(0.25).unwrap(),
1086            Vec3::new(2.0, 3.5, 5.0),
1087            1e-13,
1088        );
1089        close(
1090            curve.derivatives(0.75, 2).unwrap()[1],
1091            Vec3::new(4.0, 6.0, 8.0),
1092            1e-13,
1093        );
1094        close(
1095            curve.derivatives(0.75, 2).unwrap()[2],
1096            Vec3::default(),
1097            1e-13,
1098        );
1099    }
1100
1101    #[test]
1102    fn over_clamped_knot_vector_evaluates_without_zero_weight() {
1103        // Some STEP exporters emit a start/end knot whose multiplicity exceeds
1104        // `degree + 1` — here two coincident knot VALUES (0.0, 0.0) give the
1105        // start value effective multiplicity 7 for a cubic (max legal is 4).
1106        // The domain-start span (`degree`) then points at a zero-width knot
1107        // interval, whose basis functions are all zero — a homogeneous point
1108        // with zero weight.  `knot_find_span` must advance to the first
1109        // non-empty span so evaluation stays well defined.
1110        let knots = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
1111        let control_points = (0..7)
1112            .map(|i| Vec4::from_point(Vec3::new(i as f64, 0.0, 0.0), 1.0))
1113            .collect::<Vec<_>>();
1114        let curve = NurbsCurve::new(3, knots, control_points).unwrap();
1115        // The extra start multiplicity makes the first three control points
1116        // inert: the curve runs P[3]..P[6] over the single non-empty span.
1117        close(
1118            curve.evaluate(0.0).unwrap(),
1119            Vec3::new(3.0, 0.0, 0.0),
1120            1e-12,
1121        );
1122        close(
1123            curve.evaluate(1.0).unwrap(),
1124            Vec3::new(6.0, 0.0, 0.0),
1125            1e-12,
1126        );
1127        close(
1128            curve.evaluate(0.5).unwrap(),
1129            Vec3::new(4.5, 0.0, 0.0),
1130            1e-12,
1131        );
1132    }
1133
1134    #[test]
1135    fn rational_circle_stays_on_radius() {
1136        let curve = make_arc(
1137            Vec3::new(3.0, -2.0, 5.0),
1138            Vec3::new(1.0, 0.0, 0.0),
1139            Vec3::new(0.0, 1.0, 0.0),
1140            7.0,
1141            0.0,
1142            std::f64::consts::TAU,
1143        )
1144        .unwrap();
1145        for index in 0..=64 {
1146            let point = curve.evaluate(index as f64 / 64.0).unwrap();
1147            let radial = point.sub(Vec3::new(3.0, -2.0, 5.0));
1148            assert!((radial.length() - 7.0).abs() < 1e-12);
1149        }
1150    }
1151
1152    #[test]
1153    fn quadratic_basis_derivatives_match_finite_difference() {
1154        let curve = make_arc(
1155            Vec3::default(),
1156            Vec3::new(1.0, 0.0, 0.0),
1157            Vec3::new(0.0, 1.0, 0.0),
1158            2.0,
1159            0.2,
1160            2.7,
1161        )
1162        .unwrap();
1163        let parameter = 0.37;
1164        let step = 1e-6;
1165        let finite = curve
1166            .evaluate(parameter + step)
1167            .unwrap()
1168            .sub(curve.evaluate(parameter - step).unwrap())
1169            .scale(0.5 / step);
1170        close(finite, curve.derivatives(parameter, 1).unwrap()[1], 1e-7);
1171    }
1172
1173    #[test]
1174    fn circle_constructor_and_uniform_knots_match_public_contract() {
1175        let circle = make_circle(Vec3::new(1.0, 2.0, 3.0), Vec3::new(0.0, 0.0, 1.0), 4.0).unwrap();
1176        for index in 0..=16 {
1177            assert!(
1178                (circle
1179                    .evaluate(index as f64 / 16.0)
1180                    .unwrap()
1181                    .sub(Vec3::new(1.0, 2.0, 3.0))
1182                    .length()
1183                    - 4.0)
1184                    .abs()
1185                    < 1e-12
1186            );
1187        }
1188        assert_eq!(
1189            uniform_clamped_knots(6, 3).unwrap(),
1190            vec![0.0, 0.0, 0.0, 0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0, 1.0, 1.0, 1.0]
1191        );
1192    }
1193
1194    /// Same Gram-Schmidt the conic constructors run, recomputed independently
1195    /// so the world→local transform in the tests does not trust the code
1196    /// under test for the frame.
1197    fn orthonormal_frame(primary: Vec3, hint: Vec3) -> (Vec3, Vec3, Vec3) {
1198        let x_axis = primary.normalized().unwrap();
1199        let y_axis = hint
1200            .sub(x_axis.scale(hint.dot(x_axis)))
1201            .normalized()
1202            .unwrap();
1203        (x_axis, y_axis, x_axis.cross(y_axis))
1204    }
1205
1206    #[test]
1207    fn parabola_samples_satisfy_implicit_equation_in_local_frame() {
1208        let vertex = Vec3::new(1.0, -2.0, 3.0);
1209        let axis = Vec3::new(1.0, 1.0, 0.5);
1210        // Deliberately non-orthogonal hint: exercises the Gram-Schmidt path.
1211        let latus = Vec3::new(-1.0, 2.0, 4.0);
1212        let focal = 1.7;
1213        let (t0, t1) = (-1.2, 2.3);
1214        let curve = make_parabola(vertex, axis, latus, focal, t0, t1).unwrap();
1215        let (x_axis, y_axis, z_axis) = orthonormal_frame(axis, latus);
1216        for index in 0..20 {
1217            let t = t0 + (t1 - t0) * index as f64 / 19.0;
1218            let local = curve.evaluate(t).unwrap().sub(vertex);
1219            let (x, y, z) = (local.dot(x_axis), local.dot(y_axis), local.dot(z_axis));
1220            let residual = y * y - 4.0 * focal * x;
1221            assert!(residual.abs() < 1e-10, "t={t}: residual {residual}");
1222            assert!(z.abs() < 1e-10, "t={t}: out of plane by {z}");
1223        }
1224    }
1225
1226    #[test]
1227    fn hyperbola_samples_satisfy_implicit_equation_in_local_frame() {
1228        let center = Vec3::new(-2.0, 1.0, 4.0);
1229        let major = Vec3::new(2.0, -1.0, 1.0);
1230        let minor = Vec3::new(0.5, 3.0, 1.0);
1231        let (a, b) = (2.0, 0.9);
1232        let (t0, t1) = (-1.5, 2.2);
1233        let curve = make_hyperbola(center, major, minor, a, b, t0, t1).unwrap();
1234        let (x_axis, y_axis, z_axis) = orthonormal_frame(major, minor);
1235        for index in 0..20 {
1236            let t = t0 + (t1 - t0) * index as f64 / 19.0;
1237            let local = curve.evaluate(t).unwrap().sub(center);
1238            let (x, y, z) = (local.dot(x_axis), local.dot(y_axis), local.dot(z_axis));
1239            let residual = x * x / (a * a) - y * y / (b * b) - 1.0;
1240            assert!(residual.abs() < 1e-10, "t={t}: residual {residual}");
1241            assert!(z.abs() < 1e-10, "t={t}: out of plane by {z}");
1242            // The arc must stay on the requested x > 0 branch.
1243            assert!(x > 0.0, "t={t}: crossed to the wrong branch, x={x}");
1244        }
1245    }
1246
1247    #[test]
1248    fn parabola_reproduces_standard_form_and_derivatives_exactly() {
1249        let vertex = Vec3::new(0.5, 0.25, -1.0);
1250        let axis = Vec3::new(0.0, 0.0, 2.0);
1251        let latus = Vec3::new(3.0, 0.0, 1.0);
1252        let focal = 0.8;
1253        let (t0, t1) = (-2.0, 1.5);
1254        let curve = make_parabola(vertex, axis, latus, focal, t0, t1).unwrap();
1255        let (x_axis, y_axis, _) = orthonormal_frame(axis, latus);
1256        let expected = |t: f64| {
1257            vertex
1258                .add(x_axis.scale(focal * t * t))
1259                .add(y_axis.scale(2.0 * focal * t))
1260        };
1261        // The knot span carries the natural parameter, so the domain is
1262        // [t0, t1] verbatim and every parameter (not just the ends) matches
1263        // the standard form — the polynomial reproduction promise.
1264        assert_eq!(curve.domain().unwrap(), [t0, t1]);
1265        close(curve.evaluate(t0).unwrap(), expected(t0), 1e-12);
1266        close(curve.evaluate(t1).unwrap(), expected(t1), 1e-12);
1267        let mid = 0.5 * (t0 + t1);
1268        close(curve.evaluate(mid).unwrap(), expected(mid), 1e-12);
1269        let derivatives = curve.derivatives(0.37, 2).unwrap();
1270        close(
1271            derivatives[1],
1272            x_axis
1273                .scale(2.0 * focal * 0.37)
1274                .add(y_axis.scale(2.0 * focal)),
1275            1e-10,
1276        );
1277        close(derivatives[2], x_axis.scale(2.0 * focal), 1e-10);
1278        // Round-trip the raw data through the full construction-time checks.
1279        assert!(NurbsCurve::new(
1280            curve.degree,
1281            curve.knots.clone(),
1282            curve.control_points.clone()
1283        )
1284        .is_ok());
1285    }
1286
1287    #[test]
1288    fn hyperbola_endpoints_mid_parameter_and_weight_are_exact() {
1289        let center = Vec3::new(1.0, 2.0, 3.0);
1290        let major = Vec3::new(1.0, 0.0, 0.0);
1291        let minor = Vec3::new(0.0, 1.0, 0.0);
1292        let (a, b) = (1.25, 2.5);
1293        let (t0, t1) = (-0.75, 1.8);
1294        let curve = make_hyperbola(center, major, minor, a, b, t0, t1).unwrap();
1295        let on_curve = |t: f64| {
1296            center
1297                .add(major.scale(a * t.cosh()))
1298                .add(minor.scale(b * t.sinh()))
1299        };
1300        assert_eq!(curve.domain().unwrap(), [t0, t1]);
1301        close(curve.evaluate(t0).unwrap(), on_curve(t0), 1e-12);
1302        close(curve.evaluate(t1).unwrap(), on_curve(t1), 1e-12);
1303        // The shoulder point sits at the parameter midpoint by construction;
1304        // its exactness is what pins the middle weight derivation.
1305        let mid = 0.5 * (t0 + t1);
1306        close(curve.evaluate(mid).unwrap(), on_curve(mid), 1e-12);
1307        assert!((curve.control_points[1].w - (0.5 * (t1 - t0)).cosh()).abs() < 1e-12);
1308        // End derivative must parallel the analytic tangent a·sinh t·x +
1309        // b·cosh t·y and point along increasing t.
1310        let derivatives = curve.derivatives(t0, 1).unwrap();
1311        let tangent = major.scale(a * t0.sinh()).add(minor.scale(b * t0.cosh()));
1312        assert!(
1313            derivatives[1].cross(tangent).length()
1314                <= 1e-9 * derivatives[1].length() * tangent.length()
1315        );
1316        assert!(derivatives[1].dot(tangent) > 0.0);
1317        assert!(NurbsCurve::new(
1318            curve.degree,
1319            curve.knots.clone(),
1320            curve.control_points.clone()
1321        )
1322        .is_ok());
1323    }
1324
1325    #[test]
1326    fn conic_constructors_reject_degenerate_inputs() {
1327        let origin = Vec3::default();
1328        let x = Vec3::new(1.0, 0.0, 0.0);
1329        let y = Vec3::new(0.0, 1.0, 0.0);
1330        // Parabola: focal, frame, and range failures each carry the honest
1331        // "make_parabola:" prefix.
1332        let error = make_parabola(origin, x, y, 0.0, 0.0, 1.0).unwrap_err();
1333        assert!(error.starts_with("make_parabola:"), "{error}");
1334        assert!(make_parabola(origin, x, y, -1.0, 0.0, 1.0).is_err());
1335        assert!(make_parabola(origin, x, y, f64::NAN, 0.0, 1.0).is_err());
1336        assert!(make_parabola(origin, x, x.scale(3.0), 1.0, 0.0, 1.0).is_err());
1337        assert!(make_parabola(origin, origin, y, 1.0, 0.0, 1.0).is_err());
1338        assert!(make_parabola(origin, x, origin, 1.0, 0.0, 1.0).is_err());
1339        assert!(make_parabola(origin, x, y, 1.0, 1.0, 1.0).is_err());
1340        assert!(make_parabola(origin, x, y, 1.0, 2.0, -1.0).is_err());
1341        assert!(make_parabola(origin, x, y, 1.0, 0.0, f64::INFINITY).is_err());
1342        // Hyperbola: same contract.
1343        let error = make_hyperbola(origin, x, x, 1.0, 1.0, 0.0, 1.0).unwrap_err();
1344        assert!(error.starts_with("make_hyperbola:"), "{error}");
1345        assert!(make_hyperbola(origin, x, y, 0.0, 1.0, 0.0, 1.0).is_err());
1346        assert!(make_hyperbola(origin, x, y, 1.0, -2.0, 0.0, 1.0).is_err());
1347        assert!(make_hyperbola(origin, x, y, f64::NAN, 1.0, 0.0, 1.0).is_err());
1348        assert!(make_hyperbola(origin, x, y, 1.0, 1.0, 0.5, 0.5).is_err());
1349        assert!(make_hyperbola(origin, x, y, 1.0, 1.0, f64::NAN, 1.0).is_err());
1350        // cosh overflows f64 near |t| ~ 710: rejected with the constructor's
1351        // own message, not the generic NurbsCurve finiteness error.
1352        let error = make_hyperbola(origin, x, y, 1.0, 1.0, 0.0, 1600.0).unwrap_err();
1353        assert!(error.starts_with("make_hyperbola:"), "{error}");
1354    }
1355}