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