Skip to main content

brep_kernel/geometry/
curve.rs

1use crate::Vec3;
2use serde::{Deserialize, Serialize};
3
4const EPS: f64 = crate::tolerance::FP_FLOOR;
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`].
20///
21/// Comparand: the absolute difference of two knot values of ONE knot vector,
22/// and a knot's distance from either end of the active domain
23/// (`knots[degree]`, `knots[len − 1 − degree]`), in that curve's parameter
24/// units. Two knots within it count as one, and a knot within it of an end is
25/// not interior. The containment lanes' per-pcurve sample count
26/// ([`crate::trim_sample_count`]) counts interior knots this way.
27pub const KNOT_DEDUP_EPS: f64 = 1e-12;
28
29/// Count distinct knots strictly inside a valid knot vector's active domain.
30/// Multiplicities and values within [`KNOT_DEDUP_EPS`] of the domain ends are
31/// ignored. Used to choose sampling density for intersections and containment.
32pub(crate) fn interior_knot_count(knots: &[f64], degree: usize) -> usize {
33    distinct_interior_knots(knots, degree).count()
34}
35
36fn distinct_interior_knots(knots: &[f64], degree: usize) -> impl Iterator<Item = f64> + '_ {
37    let start = knots[degree];
38    let end = knots[knots.len() - 1 - degree];
39    let mut previous = None;
40    knots.iter().copied().filter(move |&knot| {
41        if knot <= start + KNOT_DEDUP_EPS || knot >= end - KNOT_DEDUP_EPS {
42            return false;
43        }
44        if previous.is_none_or(|value: f64| (value - knot).abs() > KNOT_DEDUP_EPS) {
45            previous = Some(knot);
46            true
47        } else {
48            false
49        }
50    })
51}
52
53#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
54pub struct Vec4 {
55    pub x: f64,
56    pub y: f64,
57    pub z: f64,
58    pub w: f64,
59}
60
61/// Construct a line in the parameter plane (z = 0).
62pub(crate) fn parameter_line(u0: f64, v0: f64, u1: f64, v1: f64) -> Result<NurbsCurve, String> {
63    make_line(Vec3::new(u0, v0, 0.0), Vec3::new(u1, v1, 0.0))
64}
65
66/// Project a curve onto plane axes while preserving its knots and rational weights.
67pub(crate) fn curve_to_plane_parameters(
68    curve: &NurbsCurve,
69    origin: Vec3,
70    x_axis: Vec3,
71    y_axis: Vec3,
72) -> Result<NurbsCurve, String> {
73    NurbsCurve::new(
74        curve.degree,
75        curve.knots.clone(),
76        curve
77            .control_points
78            .iter()
79            .map(|point| {
80                let euclidean = Vec3::new(point.x / point.w, point.y / point.w, point.z / point.w);
81                let delta = euclidean.sub(origin);
82                Vec4 {
83                    x: delta.dot(x_axis) * point.w,
84                    y: delta.dot(y_axis) * point.w,
85                    z: 0.0,
86                    w: point.w,
87                }
88            })
89            .collect(),
90    )
91}
92
93impl Vec4 {
94    pub fn from_point(point: Vec3, weight: f64) -> Self {
95        Self {
96            x: point.x * weight,
97            y: point.y * weight,
98            z: point.z * weight,
99            w: weight,
100        }
101    }
102
103    pub(crate) fn add(self, rhs: Self) -> Self {
104        Self {
105            x: self.x + rhs.x,
106            y: self.y + rhs.y,
107            z: self.z + rhs.z,
108            w: self.w + rhs.w,
109        }
110    }
111
112    pub(crate) fn scale(self, factor: f64) -> Self {
113        Self {
114            x: self.x * factor,
115            y: self.y * factor,
116            z: self.z * factor,
117            w: self.w * factor,
118        }
119    }
120
121    pub(crate) fn point(self) -> Result<Vec3, String> {
122        if self.w.abs() <= EPS {
123            return Err("cannot project a homogeneous point with zero weight".into());
124        }
125        Ok(Vec3::new(self.x / self.w, self.y / self.w, self.z / self.w))
126    }
127}
128
129/// Largest degree served by the allocation-free stack-array basis engine.
130/// Higher degrees (rare: only externally imported geometry) fall back to the
131/// heap-allocating `KnotVector` path.
132pub(crate) const MAX_STACK_DEGREE: usize = 7;
133pub(crate) const MAX_STACK_ORDER: usize = MAX_STACK_DEGREE + 1;
134
135/// `BREP_DEBUG_KNOTS=1` diagnostic for a rejected knot vector: dumps the whole
136/// vector (plus a backtrace) to stderr and appends it to the returned message,
137/// so a knot failure buried behind a `Result` that a caller only prints —
138/// STEP import's `first_error`, say — still names the offending construction
139/// site. Off by default; the message stays byte-identical without the var.
140fn knot_reject(reason: &str, knots: &[f64], degree: usize) -> String {
141    if std::env::var("BREP_DEBUG_KNOTS").is_err() {
142        return reason.to_string();
143    }
144    let mut worst_descent = f64::NEG_INFINITY;
145    let mut worst_index = 0usize;
146    for (index, pair) in knots.windows(2).enumerate() {
147        let descent = pair[0] - pair[1];
148        if descent > worst_descent {
149            worst_descent = descent;
150            worst_index = index;
151        }
152    }
153    let detail = format!(
154        "{reason} [degree={degree} count={} worst_descent={worst_descent:.6e} at index \
155         {worst_index} knots={knots:?}]",
156        knots.len()
157    );
158    eprintln!(
159        "KNOT-REJECT {detail}\n{}",
160        std::backtrace::Backtrace::force_capture()
161    );
162    detail
163}
164
165/// Validation shared by `KnotVector::new` and the lazily-validated
166/// curve/surface types. Must stay the single source of truth so cached
167/// validation and eager validation reject exactly the same inputs.
168pub(crate) fn validate_knots(knots: &[f64], degree: usize) -> Result<(), String> {
169    if degree < 1 {
170        return Err("KnotVector: degree must be >= 1".into());
171    }
172    if knots.len() < 2 * (degree + 1) {
173        return Err(format!(
174            "KnotVector: need at least {} knots for degree {}, got {}",
175            2 * (degree + 1),
176            degree,
177            knots.len()
178        ));
179    }
180    if knots.iter().any(|value| !value.is_finite()) {
181        return Err("KnotVector: knots must be finite".into());
182    }
183    if knots
184        .windows(2)
185        .any(|pair| pair[1] < pair[0] - KNOT_IDENTITY_TOL)
186    {
187        return Err(knot_reject(
188            "KnotVector: knots must be non-decreasing",
189            knots,
190            degree,
191        ));
192    }
193    let first = knots[0];
194    let last = knots[knots.len() - 1];
195    for index in 0..=degree {
196        if (knots[index] - first).abs() > KNOT_IDENTITY_TOL {
197            return Err(knot_reject(
198                "KnotVector: expected clamped start",
199                knots,
200                degree,
201            ));
202        }
203        if (knots[knots.len() - 1 - index] - last).abs() > KNOT_IDENTITY_TOL {
204            return Err(knot_reject("KnotVector: expected clamped end", knots, degree));
205        }
206    }
207    if last - first <= KNOT_IDENTITY_TOL {
208        return Err(knot_reject(
209            "KnotVector: degenerate parameter range",
210            knots,
211            degree,
212        ));
213    }
214    Ok(())
215}
216
217pub(crate) fn knot_domain(knots: &[f64], degree: usize) -> [f64; 2] {
218    [knots[degree], knots[knots.len() - 1 - degree]]
219}
220
221pub(crate) fn knot_clamp(knots: &[f64], degree: usize, parameter: f64) -> f64 {
222    let [start, end] = knot_domain(knots, degree);
223    parameter.clamp(start, end)
224}
225
226pub(crate) fn knot_find_span(knots: &[f64], degree: usize, parameter: f64) -> usize {
227    let parameter = knot_clamp(knots, degree, parameter);
228    let n = knots.len() - degree - 2;
229    if parameter >= knots[n + 1] {
230        // At/after the domain end.  A properly clamped vector has the end knot
231        // at multiplicity exactly `degree + 1`, so span `n` is the last
232        // non-empty interval.  An over-clamped end (a vendor exporter can emit
233        // multiplicity > degree + 1, e.g. two coincident knot VALUES) leaves
234        // span `n` pointing at a zero-width interval whose basis functions are
235        // all zero.  Walk back to the last interval that actually has width.
236        let mut span = n;
237        while span > degree && knots[span] >= knots[span + 1] {
238            span -= 1;
239        }
240        return span;
241    }
242    if parameter <= knots[degree] {
243        // At/before the domain start — the mirror of the case above.  For a
244        // properly clamped start (multiplicity degree + 1) this returns
245        // `degree`; for an over-clamped start it advances past the extra
246        // coincident knots to the first non-empty interval so evaluation never
247        // lands on a zero-width span (which would yield an all-zero,
248        // zero-weight homogeneous point).
249        let mut span = degree;
250        while span < n && knots[span + 1] <= parameter {
251            span += 1;
252        }
253        return span;
254    }
255    let mut low = degree;
256    let mut high = n + 1;
257    let mut middle = (low + high) / 2;
258    while parameter < knots[middle] || parameter >= knots[middle + 1] {
259        if parameter < knots[middle] {
260            high = middle;
261        } else {
262            low = middle;
263        }
264        middle = (low + high) / 2;
265    }
266    middle
267}
268
269/// Cox–de Boor basis functions into a caller-provided stack array.
270/// Requires `degree <= MAX_STACK_DEGREE`; entries `0..=degree` are written.
271pub(crate) fn basis_functions_into(
272    knots: &[f64],
273    degree: usize,
274    span: usize,
275    parameter: f64,
276    basis: &mut [f64; MAX_STACK_ORDER],
277) {
278    debug_assert!(degree <= MAX_STACK_DEGREE);
279    // The degrees every carrier the kernel builds has (1..=5) run the same
280    // recurrence with the degree and the scratch size fixed at compile time:
281    // the same operations in the same order on the same operands, so the same
282    // bits, without the eight-wide scratch fill and runtime loop bounds that
283    // were a third of a sign reading's surface station (
284    // 2026-11-02). Higher degrees keep the eight-wide scratch.
285    match degree {
286        1 => basis_functions_scratch::<2>(knots, 1, span, parameter, basis),
287        2 => basis_functions_scratch::<3>(knots, 2, span, parameter, basis),
288        3 => basis_functions_scratch::<4>(knots, 3, span, parameter, basis),
289        4 => basis_functions_scratch::<5>(knots, 4, span, parameter, basis),
290        5 => basis_functions_scratch::<6>(knots, 5, span, parameter, basis),
291        _ => basis_functions_scratch::<MAX_STACK_ORDER>(knots, degree, span, parameter, basis),
292    }
293}
294
295/// [`basis_functions_into`]'s body with `left`/`right` scratch of `ORDER`
296/// entries (`degree < ORDER`). The recurrence reads only entries `..=degree`
297/// of either, so the scratch width changes no value.
298#[inline(always)]
299fn basis_functions_scratch<const ORDER: usize>(
300    knots: &[f64],
301    degree: usize,
302    span: usize,
303    parameter: f64,
304    basis: &mut [f64; MAX_STACK_ORDER],
305) {
306    debug_assert!(degree < ORDER);
307    let mut left = [0.0f64; ORDER];
308    let mut right = [0.0f64; ORDER];
309    basis_functions_raw_into(knots, degree, span, parameter, EPS, basis, &mut left, &mut right);
310}
311
312/// Basis derivatives (The NURBS Book A2.3) into caller-provided stack rows.
313/// Writes rows `0..=min(derivative_count, degree)` of `out`; rows above the
314/// degree keep whatever the caller initialized them to (callers zero-fill,
315/// matching the heap implementation's zero rows). Requires
316/// `degree <= MAX_STACK_DEGREE` and `out.len() > derivative_count`.
317pub(crate) fn basis_derivatives_into(
318    knots: &[f64],
319    degree: usize,
320    span: usize,
321    parameter: f64,
322    derivative_count: usize,
323    out: &mut [[f64; MAX_STACK_ORDER]],
324) {
325    debug_assert!(degree <= MAX_STACK_DEGREE);
326    debug_assert!(out.len() > derivative_count);
327    // The degree is already fixed below, but A2.3's inner k loop still
328    // receives a runtime derivative count. Fix the common counts too,
329    // without changing the recurrence or the higher-order fallback.
330    if switch!("BREP_BASIS_DERIVATIVE_COUNT_FIXED", true) {
331        match derivative_count {
332            0 => return basis_derivatives_count_fixed::<0>(knots, degree, span, parameter, out),
333            1 => return basis_derivatives_count_fixed::<1>(knots, degree, span, parameter, out),
334            2 => return basis_derivatives_count_fixed::<2>(knots, degree, span, parameter, out),
335            _ => {}
336        }
337    }
338    // Degrees 1..=5 with the degree and the scratch size fixed at compile
339    // time (see `basis_functions_into`): the same operations in the same
340    // order, so the same bits; `ndu`, `left`, `right` and `a` shrink from
341    // eight-wide to `degree + 1`-wide and the loop bounds fold.
342    match degree {
343        1 => basis_derivatives_scratch::<2>(knots, 1, span, parameter, derivative_count, out),
344        2 => basis_derivatives_scratch::<3>(knots, 2, span, parameter, derivative_count, out),
345        3 => basis_derivatives_scratch::<4>(knots, 3, span, parameter, derivative_count, out),
346        4 => basis_derivatives_scratch::<5>(knots, 4, span, parameter, derivative_count, out),
347        5 => basis_derivatives_scratch::<6>(knots, 5, span, parameter, derivative_count, out),
348        _ => basis_derivatives_scratch::<MAX_STACK_ORDER>(knots, degree, span, parameter, derivative_count, out),
349    }
350}
351
352#[inline(always)]
353fn basis_derivatives_count_fixed<const COUNT: usize>(
354    knots: &[f64], degree: usize, span: usize, parameter: f64,
355    out: &mut [[f64; MAX_STACK_ORDER]],
356) {
357    match degree {
358        1 => basis_derivatives_scratch::<2>(knots, 1, span, parameter, COUNT, out),
359        2 => basis_derivatives_scratch::<3>(knots, 2, span, parameter, COUNT, out),
360        3 => basis_derivatives_scratch::<4>(knots, 3, span, parameter, COUNT, out),
361        4 => basis_derivatives_scratch::<5>(knots, 4, span, parameter, COUNT, out),
362        5 => basis_derivatives_scratch::<6>(knots, 5, span, parameter, COUNT, out),
363        _ => basis_derivatives_scratch::<MAX_STACK_ORDER>(knots, degree, span, parameter, COUNT, out),
364    }
365}
366
367/// [`basis_derivatives_into`]'s body (The NURBS Book A2.3) with every scratch
368/// array `ORDER` wide (`degree < ORDER`). The recurrence reads scratch entries
369/// `..=degree` only, and the per-order zero fill of `a[s2]` is kept (its
370/// unwritten entries are read as zero by the next order), so the width
371/// changes no value.
372#[inline(always)]
373fn basis_derivatives_scratch<const ORDER: usize>(
374    knots: &[f64],
375    degree: usize,
376    span: usize,
377    parameter: f64,
378    derivative_count: usize,
379    out: &mut [[f64; MAX_STACK_ORDER]],
380) {
381        let _reader_part = crate::reader_profile::Part::open(crate::reader_profile::BASIS);
382    debug_assert!(degree < ORDER);
383    let p = degree;
384    let n = derivative_count.min(p);
385    let mut ndu = [[0.0f64; ORDER]; ORDER];
386    let mut left = [0.0f64; ORDER];
387    let mut right = [0.0f64; ORDER];
388    ndu[0][0] = 1.0;
389
390    for j in 1..=p {
391        left[j] = parameter - knots[span + 1 - j];
392        right[j] = knots[span + j] - parameter;
393        let mut saved = 0.0;
394        for r in 0..j {
395            ndu[j][r] = right[r + 1] + left[j - r];
396            let temporary = if ndu[j][r].abs() <= EPS {
397                0.0
398            } else {
399                ndu[r][j - 1] / ndu[j][r]
400            };
401            ndu[r][j] = saved + right[r + 1] * temporary;
402            saved = left[j - r] * temporary;
403        }
404        ndu[j][j] = saved;
405    }
406
407    for j in 0..=p {
408        out[0][j] = ndu[j][p];
409    }
410    let mut a = [[0.0f64; ORDER]; 2];
411    for r in 0..=p {
412        let mut s1 = 0;
413        let mut s2 = 1;
414        a[0][0] = 1.0;
415        for k in 1..=n {
416            a[s2] = [0.0; ORDER];
417            let mut value = 0.0;
418            let rk = r as isize - k as isize;
419            let pk = p - k;
420            if r >= k {
421                let denominator = ndu[pk + 1][rk as usize];
422                a[s2][0] = a[s1][0] / denominator;
423                value = a[s2][0] * ndu[rk as usize][pk];
424            }
425            let j1 = if rk >= -1 { 1 } else { (-rk) as usize };
426            let j2 = if r <= pk + 1 { k - 1 } else { p - r };
427            if j1 <= j2 {
428                for j in j1..=j2 {
429                    let index = (rk + j as isize) as usize;
430                    a[s2][j] = (a[s1][j] - a[s1][j - 1]) / ndu[pk + 1][index];
431                    value += a[s2][j] * ndu[index][pk];
432                }
433            }
434            if r <= pk {
435                a[s2][k] = -a[s1][k - 1] / ndu[pk + 1][r];
436                value += a[s2][k] * ndu[r][pk];
437            }
438            out[k][r] = value;
439            std::mem::swap(&mut s1, &mut s2);
440        }
441    }
442    let mut factor = p as f64;
443    for k in 1..=n {
444        for value in out[k][..=p].iter_mut() {
445            *value *= factor;
446        }
447        factor *= (p - k) as f64;
448    }
449}
450
451#[derive(Clone, Debug, Deserialize, Serialize)]
452pub struct KnotVector {
453    pub knots: Vec<f64>,
454    pub degree: usize,
455}
456
457/// Distinct interior knots, excluding endpoint neighborhoods within `1e-12`.
458pub(crate) fn interior_knots(knots: &[f64], degree: usize) -> Vec<f64> {
459    distinct_interior_knots(knots, degree).collect()
460}
461
462impl KnotVector {
463    pub fn new(knots: Vec<f64>, degree: usize) -> Result<Self, String> {
464        validate_knots(&knots, degree)?;
465        Ok(Self { knots, degree })
466    }
467
468    pub fn control_point_count(&self) -> usize {
469        self.knots.len() - self.degree - 1
470    }
471
472    pub fn domain(&self) -> [f64; 2] {
473        [
474            self.knots[self.degree],
475            self.knots[self.knots.len() - 1 - self.degree],
476        ]
477    }
478
479    pub fn clamp_param(&self, parameter: f64) -> f64 {
480        knot_clamp(&self.knots, self.degree, parameter)
481    }
482
483    pub fn find_span(&self, parameter: f64) -> usize {
484        knot_find_span(&self.knots, self.degree, parameter)
485    }
486
487    pub fn basis_functions(&self, span: usize, parameter: f64) -> Vec<f64> {
488        let _reader_part = crate::reader_profile::Part::open(crate::reader_profile::BASIS);
489        if self.degree <= MAX_STACK_DEGREE {
490            let mut basis = [0.0f64; MAX_STACK_ORDER];
491            basis_functions_into(&self.knots, self.degree, span, parameter, &mut basis);
492            return basis[..=self.degree].to_vec();
493        }
494        basis_functions_raw(&self.knots, self.degree, span, parameter, EPS)
495    }
496
497    pub fn basis_derivatives(
498        &self,
499        span: usize,
500        parameter: f64,
501        derivative_count: usize,
502    ) -> Vec<Vec<f64>> {
503        let _reader_part = crate::reader_profile::Part::open(crate::reader_profile::BASIS);
504        if self.degree <= MAX_STACK_DEGREE && derivative_count <= MAX_STACK_DEGREE {
505            let mut rows = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
506            basis_derivatives_into(
507                &self.knots,
508                self.degree,
509                span,
510                parameter,
511                derivative_count,
512                &mut rows[..=derivative_count],
513            );
514            return rows[..=derivative_count]
515                .iter()
516                .map(|row| row[..=self.degree].to_vec())
517                .collect();
518        }
519        let p = self.degree;
520        let n = derivative_count.min(p);
521        let mut ndu = vec![vec![0.0; p + 1]; p + 1];
522        let mut left = vec![0.0; p + 1];
523        let mut right = vec![0.0; p + 1];
524        ndu[0][0] = 1.0;
525
526        for j in 1..=p {
527            left[j] = parameter - self.knots[span + 1 - j];
528            right[j] = self.knots[span + j] - parameter;
529            let mut saved = 0.0;
530            for r in 0..j {
531                ndu[j][r] = right[r + 1] + left[j - r];
532                let temporary = if ndu[j][r].abs() <= EPS {
533                    0.0
534                } else {
535                    ndu[r][j - 1] / ndu[j][r]
536                };
537                ndu[r][j] = saved + right[r + 1] * temporary;
538                saved = left[j - r] * temporary;
539            }
540            ndu[j][j] = saved;
541        }
542
543        let mut derivatives = vec![vec![0.0; p + 1]; derivative_count + 1];
544        for j in 0..=p {
545            derivatives[0][j] = ndu[j][p];
546        }
547        let mut a = vec![vec![0.0; p + 1]; 2];
548        for r in 0..=p {
549            let mut s1 = 0;
550            let mut s2 = 1;
551            a[0][0] = 1.0;
552            for k in 1..=n {
553                a[s2].fill(0.0);
554                let mut value = 0.0;
555                let rk = r as isize - k as isize;
556                let pk = p - k;
557                if r >= k {
558                    let denominator = ndu[pk + 1][rk as usize];
559                    a[s2][0] = a[s1][0] / denominator;
560                    value = a[s2][0] * ndu[rk as usize][pk];
561                }
562                let j1 = if rk >= -1 { 1 } else { (-rk) as usize };
563                let j2 = if r <= pk + 1 { k - 1 } else { p - r };
564                if j1 <= j2 {
565                    for j in j1..=j2 {
566                        let index = (rk + j as isize) as usize;
567                        a[s2][j] = (a[s1][j] - a[s1][j - 1]) / ndu[pk + 1][index];
568                        value += a[s2][j] * ndu[index][pk];
569                    }
570                }
571                if r <= pk {
572                    a[s2][k] = -a[s1][k - 1] / ndu[pk + 1][r];
573                    value += a[s2][k] * ndu[r][pk];
574                }
575                derivatives[k][r] = value;
576                std::mem::swap(&mut s1, &mut s2);
577            }
578        }
579        let mut factor = p as f64;
580        for (k, row) in derivatives.iter_mut().enumerate().take(n + 1).skip(1) {
581            for value in row {
582                *value *= factor;
583            }
584            factor *= (p - k) as f64;
585        }
586        derivatives
587    }
588}
589
590/// A rational B-spline curve.
591///
592/// **Invariant: an in-place edit of the public fields must keep the curve
593/// valid.** The curve caches that it passed validation (finite values,
594/// positive weights, a clamped knot vector with a positive domain), and
595/// `Clone` copies that flag; nothing re-checks a field edited in place. Prefer
596/// building a new value with [`NurbsCurve::new`].
597#[derive(Clone, Debug, Deserialize, Serialize)]
598pub struct NurbsCurve {
599    pub degree: usize,
600    pub knots: Vec<f64>,
601    pub control_points: Vec<Vec4>,
602    /// One-time validation cache. Deserialized curves (which bypass `new`)
603    /// run the full `new`-equivalent checks on first geometric use instead of
604    /// re-validating the knot vector on every evaluation.
605    #[serde(skip, default)]
606    validated: std::cell::Cell<bool>,
607}
608
609impl NurbsCurve {
610    /// Whether the one-time validation has run. For the boolean's
611    /// operand-state instrument (`BREP_DEBUG_OPERAND_STATE`).
612    pub(crate) fn is_validated(&self) -> bool {
613        self.validated.get()
614    }
615
616    /// Sets the validation cache: for the operand-state instrument only.
617    pub(crate) fn set_validated(&mut self, validated: bool) {
618        self.validated = std::cell::Cell::new(validated);
619    }
620
621    pub fn new(degree: usize, knots: Vec<f64>, control_points: Vec<Vec4>) -> Result<Self, String> {
622        let curve = Self {
623            degree,
624            knots,
625            control_points,
626            validated: std::cell::Cell::new(false),
627        };
628        curve.ensure_valid()?;
629        Ok(curve)
630    }
631
632    /// The full construction-time checks, run at most once per instance.
633    fn ensure_valid(&self) -> Result<(), String> {
634        if self.validated.get() {
635            return Ok(());
636        }
637        validate_knots(&self.knots, self.degree)?;
638        let expected = self.knots.len() - self.degree - 1;
639        if self.control_points.len() != expected {
640            return Err(format!(
641                "NurbsCurve: knot vector implies {} control points, got {}",
642                expected,
643                self.control_points.len()
644            ));
645        }
646        if self.control_points.iter().any(|point| {
647            point.w <= EPS
648                || ![point.x, point.y, point.z, point.w]
649                    .iter()
650                    .all(|value| value.is_finite())
651        }) {
652            return Err("NurbsCurve: control points must be finite with positive weights".into());
653        }
654        self.validated.set(true);
655        Ok(())
656    }
657
658    fn knot_vector(&self) -> Result<KnotVector, String> {
659        KnotVector::new(self.knots.clone(), self.degree)
660    }
661
662    pub fn domain(&self) -> Result<[f64; 2], String> {
663        self.ensure_valid()?;
664        Ok(knot_domain(&self.knots, self.degree))
665    }
666
667    #[cfg_attr(feature = "profile-wall", track_caller)]
668    pub fn evaluate_homogeneous(&self, parameter: f64) -> Result<Vec4, String> {
669        let _reader = crate::reader_profile::Point::open(crate::reader_profile::CURVE, std::panic::Location::caller());
670    let _wall = crate::wall_profile::Scope::enter(crate::wall_profile::POINT);
671        self.ensure_valid()?;
672        let span = knot_find_span(&self.knots, self.degree, parameter);
673        let parameter = knot_clamp(&self.knots, self.degree, parameter);
674        let mut point = Vec4 {
675            x: 0.0,
676            y: 0.0,
677            z: 0.0,
678            w: 0.0,
679        };
680        if self.degree <= MAX_STACK_DEGREE {
681            let mut basis = [0.0f64; MAX_STACK_ORDER];
682            basis_functions_into(&self.knots, self.degree, span, parameter, &mut basis);
683            let _reader_part = crate::reader_profile::Part::open(crate::reader_profile::ACCUMULATE);
684            for (index, value) in basis[..=self.degree].iter().enumerate() {
685                point = point.add(self.control_points[span - self.degree + index].scale(*value));
686            }
687        } else {
688            let knot_vector = self.knot_vector()?;
689            let basis = knot_vector.basis_functions(span, parameter);
690            let _reader_part = crate::reader_profile::Part::open(crate::reader_profile::ACCUMULATE);
691            for (index, value) in basis.iter().enumerate() {
692                point = point.add(self.control_points[span - self.degree + index].scale(*value));
693            }
694        }
695        Ok(point)
696    }
697
698    #[cfg_attr(feature = "profile-wall", track_caller)]
699    pub fn evaluate(&self, parameter: f64) -> Result<Vec3, String> {
700        let _reader = crate::reader_profile::Point::open(crate::reader_profile::CURVE, std::panic::Location::caller());
701    let _wall = crate::wall_profile::Scope::enter(crate::wall_profile::POINT);
702        self.evaluate_homogeneous(parameter)?.point()
703    }
704
705    /// The straight segment this curve traces, as `(start, end)`, or `None`
706    /// when it genuinely bends.
707    ///
708    /// What a direct-edit heal actually needs to know about an edge is
709    /// *geometric* — "is this a straight segment I may rebuild between two
710    /// re-solved corners?" — but it used to be answered by the
711    /// *representation* `degree == 1 && control_points.len() == 2`. That
712    /// answer is wrong for a line that carries an interior knot, which is what
713    /// an offset shell or a boolean leaves behind when it splits a box edge and
714    /// rejoins the pieces: the same line, three control points, and every heal
715    /// that asked the representation question called it "curved".
716    ///
717    /// Weights are strictly positive (`ensure_valid`), so every point of the
718    /// curve is a convex combination of its control points and the curve is a
719    /// straight segment **iff** those control points are collinear — for any
720    /// degree, not just degree 1. Monotone projections onto the chord are
721    /// required too, so a polyline that doubles back along its own line (it
722    /// covers the segment but does not parameterise it) is not mistaken for
723    /// one.
724    pub(crate) fn straight_segment(&self, tolerance: f64) -> Option<(Vec3, Vec3)> {
725        self.ensure_valid().ok()?;
726        let points = self
727            .control_points
728            .iter()
729            .map(|point| point.point().ok())
730            .collect::<Option<Vec<Vec3>>>()?;
731        let start = *points.first()?;
732        let end = *points.last()?;
733        let chord = end.sub(start);
734        let length = chord.length();
735        if length <= tolerance {
736            return None;
737        }
738        let direction = chord.scale(1.0 / length);
739        let mut reached = 0.0f64;
740        for point in &points {
741            let offset = point.sub(start);
742            let axial = offset.dot(direction);
743            if offset.sub(direction.scale(axial)).length() > tolerance {
744                return None; // off the chord — the curve genuinely bends
745            }
746            if axial < reached - tolerance {
747                return None; // collinear, but the polygon doubles back
748            }
749            reached = reached.max(axial);
750        }
751        Some((start, end))
752    }
753
754    /// Value beyond the domain (Golovanov §2.15): every curve must answer
755    /// out-of-domain queries because intersection and projection
756    /// algorithms probe there.  Closed curves wrap the parameter
757    /// cyclically; open curves extend linearly along the end tangent.
758    #[cfg_attr(feature = "profile-wall", track_caller)]
759    pub fn evaluate_extended(&self, parameter: f64) -> Result<Vec3, String> {
760        let _reader = crate::reader_profile::Point::open(crate::reader_profile::CURVE, std::panic::Location::caller());
761    let _wall = crate::wall_profile::Scope::enter(crate::wall_profile::POINT);
762        Ok(self.derivatives_extended(parameter, 0)?[0])
763    }
764
765    /// Derivatives beyond the domain (§2.15).  The open-end extension is
766    /// linear: the first derivative is the boundary tangent and higher
767    /// derivatives vanish.
768    pub fn derivatives_extended(
769        &self,
770        parameter: f64,
771        derivative_count: usize,
772    ) -> Result<Vec<Vec3>, String> {
773        let [start, end] = self.domain()?;
774        if parameter >= start && parameter <= end {
775            return self.derivatives(parameter, derivative_count);
776        }
777        let period = end - start;
778        if period > 0.0 {
779            let closed =
780                self.evaluate(start)?.sub(self.evaluate(end)?).length() <= 1e-9 * (1.0 + period);
781            if closed {
782                let wrapped = start + (parameter - start).rem_euclid(period);
783                return self.derivatives(wrapped, derivative_count);
784            }
785        }
786        let boundary = if parameter < start { start } else { end };
787        let base = self.derivatives(boundary, derivative_count.max(1))?;
788        let mut result = Vec::with_capacity(derivative_count + 1);
789        result.push(base[0].add(base[1].scale(parameter - boundary)));
790        if derivative_count >= 1 {
791            result.push(base[1]);
792        }
793        for _ in 2..=derivative_count {
794            result.push(Vec3::default());
795        }
796        Ok(result)
797    }
798
799    pub fn derivatives(
800        &self,
801        parameter: f64,
802        derivative_count: usize,
803    ) -> Result<Vec<Vec3>, String> {
804        if derivative_count <= 2 && small_curve_derivatives_enabled() {
805            return Ok(self.derivatives_small(parameter, derivative_count)?[..=derivative_count].to_vec());
806        }
807        self.ensure_valid()?;
808        let parameter = knot_clamp(&self.knots, self.degree, parameter);
809        let span = knot_find_span(&self.knots, self.degree, parameter);
810        let calculated_count = derivative_count.min(self.degree);
811        let mut homogeneous: Vec<Vec4> = Vec::with_capacity(calculated_count + 1);
812        if self.degree <= MAX_STACK_DEGREE {
813            let mut rows = [[0.0f64; MAX_STACK_ORDER]; MAX_STACK_ORDER];
814            basis_derivatives_into(
815                &self.knots,
816                self.degree,
817                span,
818                parameter,
819                calculated_count,
820                &mut rows[..=calculated_count],
821            );
822            let _reader_part = crate::reader_profile::Part::open(crate::reader_profile::ACCUMULATE);
823            for row in rows.iter().take(calculated_count + 1) {
824                let mut point = Vec4 {
825                    x: 0.0,
826                    y: 0.0,
827                    z: 0.0,
828                    w: 0.0,
829                };
830                for (index, value) in row.iter().enumerate().take(self.degree + 1) {
831                    point =
832                        point.add(self.control_points[span - self.degree + index].scale(*value));
833                }
834                homogeneous.push(point);
835            }
836        } else {
837            let knot_vector = self.knot_vector()?;
838            let basis = knot_vector.basis_derivatives(span, parameter, calculated_count);
839            let _reader_part = crate::reader_profile::Part::open(crate::reader_profile::ACCUMULATE);
840            for row in basis.iter().take(calculated_count + 1) {
841                let mut point = Vec4 {
842                    x: 0.0,
843                    y: 0.0,
844                    z: 0.0,
845                    w: 0.0,
846                };
847                for (index, value) in row.iter().enumerate().take(self.degree + 1) {
848                    point =
849                        point.add(self.control_points[span - self.degree + index].scale(*value));
850                }
851                homogeneous.push(point);
852            }
853        }
854
855        let mut result: Vec<Vec3> = Vec::with_capacity(derivative_count + 1);
856        for k in 0..=calculated_count {
857            let mut value = Vec3::new(homogeneous[k].x, homogeneous[k].y, homogeneous[k].z);
858            for i in 1..=k {
859                value = value.sub(result[k - i].scale(binomial(k, i) * homogeneous[i].w));
860            }
861            result.push(value.scale(1.0 / homogeneous[0].w));
862        }
863        result.resize(derivative_count + 1, Vec3::default());
864        Ok(result)
865    }
866
867    /// The curve's unit TANGENT at `parameter`, read as the one-sided limit
868    /// when the first derivative vanishes there.
869    ///
870    /// This is what `derivatives(t, 1)?[1].normalized()?` should always have
871    /// been. That shape refuses on a curve class that is not exotic at all —
872    /// any Hermite segment whose end tangent is the zero vector, of which an
873    /// INVOLUTE flank is the standard source (speed `r_base * t`, so a profile
874    /// sampled from the base circle outwards carries an exactly zero tangent at
875    /// its start, one per flank per tooth). The direction of travel is still
876    /// defined there, as the limit from inside the range, and
877    /// [`Self::stationary_tangent_rescue`] reads it.
878    ///
879    /// Pass the range the CALLER owns — an edge's `[t0, t1]`, not the curve's
880    /// domain, where the two differ (an `EdgeRecord` keeps the whole curve and
881    /// records the sub-range it uses), so the probe never reads geometry
882    /// outside the caller's own span.
883    ///
884    /// The error names the parameter and the range it probed, because the only
885    /// curve that reaches it is stationary EVERYWHERE the range allows, and the
886    /// caller's own identifier is the half this function cannot supply.
887    ///
888    /// Escape hatch for tamper-verification: `BREP_CUSP_TANGENT_RESCUE=0`
889    /// restores the bare `normalized()` refusal, the same hatch the imprint
890    /// driver's seed loop carries.
891    pub fn unit_tangent(
892        &self,
893        parameter: f64,
894        range_start: f64,
895        range_end: f64,
896    ) -> Result<Vec3, String> {
897        let derivative = self.derivatives(parameter, 1)?[1];
898        self.tangent_or_one_sided_limit(derivative, parameter, range_start, range_end)
899    }
900
901    /// The point and unit tangent that `derivatives_extended(parameter, 1)`
902    /// reads, with the tangent taken as [`Self::unit_tangent`] takes it.
903    ///
904    /// The blend march reads its section plane — the point and the edge's
905    /// unit tangent — through the EXTENDED evaluation at every station,
906    /// overshoot stations past the edge's ends included, and `unit_tangent`
907    /// cannot stand in for that read: it evaluates `derivatives`, which CLAMPS
908    /// a parameter outside the domain, where `derivatives_extended` WRAPS it on
909    /// a closed curve. A closed rim's overshoot station would read a different
910    /// tangent. This is the same one evaluation the march always made, so a
911    /// non-zero derivative returns the vector `derivatives[1].normalized()`
912    /// returned, to the bit, at no extra cost.
913    ///
914    /// Where the derivative vanishes, the one-sided limit is read at the
915    /// parameter the extended evaluation actually took its derivative from —
916    /// the parameter itself inside the domain, the wrapped one on a closed
917    /// curve, the boundary past an open end, where the extension's direction
918    /// IS the boundary's one-sided limit. The caller's range is widened to
919    /// contain that parameter, because an overshoot station reads outside the
920    /// caller's span on purpose; the ladder still probes from the side the
921    /// range lies on.
922    pub fn point_and_unit_tangent_extended(
923        &self,
924        parameter: f64,
925        range_start: f64,
926        range_end: f64,
927    ) -> Result<(Vec3, Vec3), String> {
928        let derivatives = self.derivatives_extended(parameter, 1)?;
929        if let Ok(tangent) = derivatives[1].normalized() {
930            return Ok((derivatives[0], tangent));
931        }
932        let read_at = self.extended_read_parameter(parameter)?;
933        let low = range_start.min(range_end).min(read_at);
934        let high = range_start.max(range_end).max(read_at);
935        let tangent = self.tangent_or_one_sided_limit(derivatives[1], read_at, low, high)?;
936        Ok((derivatives[0], tangent))
937    }
938
939    /// The in-domain parameter whose derivatives `derivatives_extended` reads
940    /// for `parameter`: itself inside the domain, wrapped on a closed curve,
941    /// the nearer boundary past an open end. Mirrors that function's three
942    /// cases; only the zero-derivative lane of
943    /// [`Self::point_and_unit_tangent_extended`] pays for it.
944    fn extended_read_parameter(&self, parameter: f64) -> Result<f64, String> {
945        let [start, end] = self.domain()?;
946        if parameter >= start && parameter <= end {
947            return Ok(parameter);
948        }
949        if self.extension_wraps(start, end)? {
950            return Ok(start + (parameter - start).rem_euclid(end - start));
951        }
952        Ok(if parameter < start { start } else { end })
953    }
954
955    /// `derivatives_extended`'s closed test: a curve whose ends meet WRAPS an
956    /// out-of-domain parameter instead of extending it.
957    fn extension_wraps(&self, start: f64, end: f64) -> Result<bool, String> {
958        let period = end - start;
959        Ok(period > 0.0
960            && self.evaluate(start)?.sub(self.evaluate(end)?).length() <= 1e-9 * (1.0 + period))
961    }
962
963    /// The open end `parameter` lies past, when the first derivative VANISHES
964    /// there — the one case in which `derivatives_extended`'s point does not
965    /// move with its parameter: past an open end it returns
966    /// `P(end) + C'(end)·(t − end)`, which is `P(end)` for every `t` when
967    /// `C'(end) = 0`. `None` inside the domain, on a closed curve (which wraps
968    /// onto the real curve), and past an end whose derivative is regular.
969    ///
970    /// The zero test is [`Self::point_and_unit_tangent_extended`]'s own: the
971    /// derivative has no unit vector. A caller that extends past such an end
972    /// supplies the SPEED the parameterization does not; the direction is the
973    /// one-sided limit that function already reads there.
974    pub(crate) fn stationary_open_end(&self, parameter: f64) -> Result<Option<f64>, String> {
975        let [start, end] = self.domain()?;
976        if parameter >= start && parameter <= end {
977            return Ok(None);
978        }
979        let boundary = if parameter < start { start } else { end };
980        if self.derivatives(boundary, 1)?[1].normalized().is_ok()
981            || self.extension_wraps(start, end)?
982        {
983            return Ok(None);
984        }
985        Ok(Some(boundary))
986    }
987
988    /// `derivative`, normalized — or, where it vanishes, the one-sided limit
989    /// from inside `[range_start, range_end]` at `parameter`, or the named
990    /// refusal. The one failure lane [`Self::unit_tangent`] and
991    /// [`Self::point_and_unit_tangent_extended`] share.
992    fn tangent_or_one_sided_limit(
993        &self,
994        derivative: Vec3,
995        parameter: f64,
996        range_start: f64,
997        range_end: f64,
998    ) -> Result<Vec3, String> {
999        let error = match derivative.normalized() {
1000            Ok(tangent) => return Ok(tangent),
1001            Err(error) => error,
1002        };
1003        if std::env::var("BREP_CUSP_TANGENT_RESCUE").as_deref() == Ok("0") {
1004            return Err(error);
1005        }
1006        self.stationary_tangent_rescue(parameter, range_start, range_end)
1007            .ok_or_else(|| {
1008                format!(
1009                    "curve tangent: the first derivative vanishes at t = {parameter} and \
1010                     everywhere in [{range_start}, {range_end}] the probe can reach, so this \
1011                     curve has no direction of travel to report"
1012                )
1013            })
1014    }
1015
1016    /// The unit tangent just inside `[range_start, range_end]`, for a
1017    /// `parameter` where the first derivative VANISHES.
1018    ///
1019    /// A curve can be stationary at a parameter where the curve itself is
1020    /// perfectly well behaved, and the direction of travel is then still
1021    /// defined as the one-sided limit. The standard case is a cubic Bezier
1022    /// whose first two control points coincide — the shape a Hermite segment
1023    /// takes when its end tangent is the zero vector — and an INVOLUTE is the
1024    /// standard source of one: the involute's speed is `r_base * t`, so a gear
1025    /// flank sampled from the base circle outwards starts with an exactly zero
1026    /// tangent and every tooth profile carries one per flank. The 2026-09-14
1027    /// herringbone report is that shape: a hit landing exactly on the cusp
1028    /// parameter made `derivatives(t, 1)[1].normalized()` fail and killed the
1029    /// whole union.
1030    ///
1031    /// The range bounds the probe — pass an edge's own `[t0, t1]` and the
1032    /// rescue never reads the curve outside the span the caller owns. The
1033    /// ladder walks OUTWARDS, and it starts at 1e-6 of the span rather than at
1034    /// the smallest representable step: the rescued direction is a ONE-SIDED
1035    /// limit, so a nearer probe truncates less, but the derivative at a
1036    /// stationary point is a CANCELLING sum that shrinks with the step while
1037    /// its rounding error does not — at 1e-9 of the span the involute cusp
1038    /// below reads its own limit direction only to 1e-6, worse than at 1e-6 of
1039    /// the span. Later rungs exist for a higher-order stationary point, whose
1040    /// derivative is still under the zero-length floor at the first. `None`
1041    /// means the curve is stationary everywhere the range allows, where no
1042    /// direction exists to report at all.
1043    pub fn stationary_tangent_rescue(
1044        &self,
1045        parameter: f64,
1046        range_start: f64,
1047        range_end: f64,
1048    ) -> Option<Vec3> {
1049        let low = range_start.min(range_end);
1050        let high = range_start.max(range_end);
1051        let span = high - low;
1052        if !(span > 0.0) {
1053            return None;
1054        }
1055        for scale in [1e-6, 1e-4, 1e-2, 1e-1] {
1056            let step = span * scale;
1057            for probe in [parameter + step, parameter - step] {
1058                if probe < low || probe > high {
1059                    continue;
1060                }
1061                if let Some(unit) = self
1062                    .derivatives(probe, 1)
1063                    .ok()
1064                    .and_then(|derivatives| derivatives[1].normalized().ok())
1065                {
1066                    return Some(unit);
1067                }
1068            }
1069        }
1070        None
1071    }
1072
1073    /// Allocation-free twin of [`Self::derivatives`] for the hot
1074    /// `derivative_count <= 2` path.
1075    ///
1076    /// Byte-for-byte faithful copy of the arithmetic in [`Self::derivatives`]:
1077    /// identical basis evaluation, identical summation order, and the identical
1078    /// rational de-homogenization recurrence. Only the storage differs — a
1079    /// fixed-size `[Vec4; 3]` / `[Vec3; 3]` on the stack instead of the heap
1080    /// `Vec`s. Entries beyond `derivative_count.min(degree)` stay
1081    /// `Vec3::default()`, exactly as the heap version's trailing `resize` leaves
1082    /// them.
1083    pub(crate) fn derivatives_small(
1084        &self,
1085        parameter: f64,
1086        derivative_count: usize,
1087    ) -> Result<[Vec3; 3], String> {
1088        debug_assert!(derivative_count <= 2);
1089        self.ensure_valid()?;
1090        let parameter = knot_clamp(&self.knots, self.degree, parameter);
1091        let span = knot_find_span(&self.knots, self.degree, parameter);
1092        let calculated_count = derivative_count.min(self.degree);
1093        let zero = Vec4 {
1094            x: 0.0,
1095            y: 0.0,
1096            z: 0.0,
1097            w: 0.0,
1098        };
1099        let mut homogeneous = [zero; 3];
1100        if self.degree <= MAX_STACK_DEGREE {
1101            // Rows `..=calculated_count` (at most three) are the only ones
1102            // written or read; the eight-row fill was pure cost.
1103            let mut rows = [[0.0f64; MAX_STACK_ORDER]; 3];
1104            basis_derivatives_into(
1105                &self.knots,
1106                self.degree,
1107                span,
1108                parameter,
1109                calculated_count,
1110                &mut rows[..=calculated_count],
1111            );
1112            let _reader_part = crate::reader_profile::Part::open(crate::reader_profile::ACCUMULATE);
1113            for (k, row) in rows.iter().take(calculated_count + 1).enumerate() {
1114                let mut point = zero;
1115                for (index, value) in row.iter().enumerate().take(self.degree + 1) {
1116                    point =
1117                        point.add(self.control_points[span - self.degree + index].scale(*value));
1118                }
1119                homogeneous[k] = point;
1120            }
1121        } else {
1122            let knot_vector = self.knot_vector()?;
1123            let basis = knot_vector.basis_derivatives(span, parameter, calculated_count);
1124            let _reader_part = crate::reader_profile::Part::open(crate::reader_profile::ACCUMULATE);
1125            for (k, row) in basis.iter().take(calculated_count + 1).enumerate() {
1126                let mut point = zero;
1127                for (index, value) in row.iter().enumerate().take(self.degree + 1) {
1128                    point =
1129                        point.add(self.control_points[span - self.degree + index].scale(*value));
1130                }
1131                homogeneous[k] = point;
1132            }
1133        }
1134
1135        let mut result = [Vec3::default(); 3];
1136        for k in 0..=calculated_count {
1137            let mut value = Vec3::new(homogeneous[k].x, homogeneous[k].y, homogeneous[k].z);
1138            for i in 1..=k {
1139                value = value.sub(result[k - i].scale(binomial(k, i) * homogeneous[i].w));
1140            }
1141            result[k] = value.scale(1.0 / homogeneous[0].w);
1142        }
1143        Ok(result)
1144    }
1145
1146    /// Point and first derivative `(C, C')` with zero heap allocation.
1147    /// Bit-identical to `derivatives(t, 1)` at indices `[0]` and `[1]`.
1148    #[inline]
1149    pub(crate) fn deriv1(&self, parameter: f64) -> Result<(Vec3, Vec3), String> {
1150        let d = self.derivatives_small(parameter, 1)?;
1151        Ok((d[0], d[1]))
1152    }
1153
1154    pub fn reversed(&self) -> Result<Self, String> {
1155        let start = self.knots[0];
1156        let end = self.knots[self.knots.len() - 1];
1157        let knots = self
1158            .knots
1159            .iter()
1160            .rev()
1161            .map(|knot| start + end - knot)
1162            .collect();
1163        let control_points = self.control_points.iter().rev().copied().collect();
1164        Self::new(self.degree, knots, control_points)
1165    }
1166
1167    pub fn insert_knot(&self, parameter: f64, requested: usize) -> Result<Self, String> {
1168        let knot_vector = self.knot_vector()?;
1169        let degree = self.degree;
1170        let parameter = knot_vector.clamp_param(parameter);
1171        // A parameter within knot tolerance of an existing knot must BE
1172        // that knot: counting it as a multiplicity while find_span places
1173        // it in the span below (parameter infinitesimally smaller) makes
1174        // the Boehm index bookkeeping inconsistent and corrupts the net.
1175        let parameter = self
1176            .knots
1177            .iter()
1178            .copied()
1179            .find(|knot| (knot - parameter).abs() <= KNOT_IDENTITY_TOL)
1180            .unwrap_or(parameter);
1181        let multiplicity = self
1182            .knots
1183            .iter()
1184            .filter(|knot| (**knot - parameter).abs() <= KNOT_IDENTITY_TOL)
1185            .count();
1186        let insertion_count = requested.min(degree.saturating_sub(multiplicity));
1187        if insertion_count == 0 {
1188            return Ok(self.clone());
1189        }
1190        let span = knot_vector.find_span(parameter);
1191        let last_control = self.control_points.len() - 1;
1192        let mut knots = Vec::with_capacity(self.knots.len() + insertion_count);
1193        knots.extend_from_slice(&self.knots[..=span]);
1194        knots.extend(std::iter::repeat_n(parameter, insertion_count));
1195        knots.extend_from_slice(&self.knots[span + 1..]);
1196
1197        let mut output = vec![
1198            Vec4 {
1199                x: 0.0,
1200                y: 0.0,
1201                z: 0.0,
1202                w: 1.0,
1203            };
1204            last_control + 1 + insertion_count
1205        ];
1206        output[..=span - degree].copy_from_slice(&self.control_points[..=span - degree]);
1207        for index in span - multiplicity..=last_control {
1208            output[index + insertion_count] = self.control_points[index];
1209        }
1210        let mut affected = vec![
1211            Vec4 {
1212                x: 0.0,
1213                y: 0.0,
1214                z: 0.0,
1215                w: 1.0,
1216            };
1217            degree + 1
1218        ];
1219        affected[..=degree - multiplicity]
1220            .copy_from_slice(&self.control_points[span - degree..=span - multiplicity]);
1221        let mut left = 0;
1222        for insertion in 1..=insertion_count {
1223            left = span - degree + insertion;
1224            for index in 0..=degree - insertion - multiplicity {
1225                let denominator = self.knots[index + span + 1] - self.knots[left + index];
1226                let alpha = (parameter - self.knots[left + index]) / denominator;
1227                affected[index] = affected[index + 1]
1228                    .scale(alpha)
1229                    .add(affected[index].scale(1.0 - alpha));
1230            }
1231            output[left] = affected[0];
1232            output[span + insertion_count - insertion - multiplicity] =
1233                affected[degree - insertion - multiplicity];
1234        }
1235        for index in left + 1..span - multiplicity {
1236            output[index] = affected[index - left];
1237        }
1238        Self::new(degree, knots, output)
1239    }
1240
1241    pub fn split(&self, parameter: f64) -> Result<(Self, Self), String> {
1242        let [start, end] = self.domain()?;
1243        if parameter <= start + KNOT_IDENTITY_TOL || parameter >= end - KNOT_IDENTITY_TOL {
1244            return Err(format!(
1245                "NurbsCurve.split: parameter {parameter} must be strictly inside domain [{start}, {end}]"
1246            ));
1247        }
1248        // Snap onto a coincident knot so multiplicity and span agree (see
1249        // insert_knot).
1250        let parameter = self
1251            .knots
1252            .iter()
1253            .copied()
1254            .find(|knot| (knot - parameter).abs() <= KNOT_IDENTITY_TOL)
1255            .unwrap_or(parameter);
1256        let multiplicity = self
1257            .knots
1258            .iter()
1259            .filter(|knot| (**knot - parameter).abs() <= KNOT_IDENTITY_TOL)
1260            .count();
1261        let refined = self.insert_knot(parameter, self.degree.saturating_sub(multiplicity))?;
1262        let first = refined
1263            .knots
1264            .iter()
1265            .position(|knot| (*knot - parameter).abs() <= KNOT_IDENTITY_TOL)
1266            .ok_or_else(|| "NurbsCurve.split: inserted knot not found".to_string())?;
1267        let mut left_knots = refined.knots[..first + self.degree].to_vec();
1268        left_knots.push(parameter);
1269        let left_points = refined.control_points[..first].to_vec();
1270        let mut right_knots = vec![parameter; self.degree + 1];
1271        right_knots.extend_from_slice(&refined.knots[first + self.degree..]);
1272        let right_points = refined.control_points[first - 1..].to_vec();
1273        Ok((
1274            Self::new(self.degree, left_knots, left_points)?,
1275            Self::new(self.degree, right_knots, right_points)?,
1276        ))
1277    }
1278}
1279
1280pub fn make_line(start: Vec3, end: Vec3) -> Result<NurbsCurve, String> {
1281    NurbsCurve::new(
1282        1,
1283        vec![0.0, 0.0, 1.0, 1.0],
1284        vec![Vec4::from_point(start, 1.0), Vec4::from_point(end, 1.0)],
1285    )
1286}
1287
1288pub fn make_arc(
1289    center: Vec3,
1290    x_axis: Vec3,
1291    y_axis: Vec3,
1292    radius: f64,
1293    start_angle: f64,
1294    end_angle: f64,
1295) -> Result<NurbsCurve, String> {
1296    if radius <= EPS {
1297        return Err("makeArc: radius must be positive".into());
1298    }
1299    let x_axis = x_axis.normalized()?;
1300    let y_axis = y_axis.normalized()?;
1301    if x_axis.dot(y_axis).abs() > 1e-9 {
1302        return Err("makeArc: xAxis and yAxis must be orthogonal".into());
1303    }
1304    let mut theta = end_angle - start_angle;
1305    if theta <= EPS {
1306        return Err("makeArc: endAngle must exceed startAngle".into());
1307    }
1308    if theta > std::f64::consts::TAU + EPS {
1309        return Err("makeArc: sweep exceeds full circle".into());
1310    }
1311    theta = theta.min(std::f64::consts::TAU);
1312    let segment_count = ((theta / std::f64::consts::FRAC_PI_2 - EPS).ceil() as usize).clamp(1, 4);
1313    let segment_angle = theta / segment_count as f64;
1314    let middle_weight = (segment_angle / 2.0).cos();
1315    let point_at = |angle: f64| {
1316        center
1317            .add(x_axis.scale(radius * angle.cos()))
1318            .add(y_axis.scale(radius * angle.sin()))
1319    };
1320    let tangent_at = |angle: f64| x_axis.scale(-angle.sin()).add(y_axis.scale(angle.cos()));
1321
1322    let mut points = Vec::with_capacity(2 * segment_count + 1);
1323    let mut angle = start_angle;
1324    let mut first_point = point_at(angle);
1325    let mut first_tangent = tangent_at(angle);
1326    points.push(Vec4::from_point(first_point, 1.0));
1327    for _ in 0..segment_count {
1328        angle += segment_angle;
1329        let end_point = point_at(angle);
1330        let end_tangent = tangent_at(angle);
1331        let cross = first_tangent.cross(end_tangent);
1332        let denominator = cross.length_squared();
1333        if denominator <= EPS {
1334            return Err("makeArc: arc tangents are parallel".into());
1335        }
1336        let distance = end_point.sub(first_point).cross(end_tangent).dot(cross) / denominator;
1337        let middle = first_point.add(first_tangent.scale(distance));
1338        points.push(Vec4::from_point(middle, middle_weight));
1339        points.push(Vec4::from_point(end_point, 1.0));
1340        first_point = end_point;
1341        first_tangent = end_tangent;
1342    }
1343    let mut knots = vec![0.0, 0.0, 0.0];
1344    for index in 1..segment_count {
1345        let knot = index as f64 / segment_count as f64;
1346        knots.extend([knot, knot]);
1347    }
1348    knots.extend([1.0, 1.0, 1.0]);
1349    NurbsCurve::new(2, knots, points)
1350}
1351
1352pub fn make_circle(center: Vec3, normal: Vec3, radius: f64) -> Result<NurbsCurve, String> {
1353    let normal = normal.normalized()?;
1354    let x_axis = normal.perpendicular()?;
1355    let y_axis = normal.cross(x_axis).normalized()?;
1356    make_arc(center, x_axis, y_axis, radius, 0.0, std::f64::consts::TAU)
1357}
1358
1359/// Build the exactly orthonormal local frame shared by the conic
1360/// constructors.  The conic algebra below (implicit equation, tangent
1361/// intersection, shoulder weight) is only exact in an orthonormal frame, so
1362/// the in-plane hint is Gram-Schmidt-projected against the primary direction
1363/// instead of trusted verbatim: callers only owe us non-parallel vectors.
1364fn conic_frame(fn_name: &str, primary: Vec3, hint: Vec3) -> Result<(Vec3, Vec3), String> {
1365    let x_axis = primary
1366        .normalized()
1367        .map_err(|_| format!("{fn_name}: primary axis must be non-zero"))?;
1368    if hint.length() <= EPS {
1369        return Err(format!("{fn_name}: in-plane direction must be non-zero"));
1370    }
1371    hint.sub(x_axis.scale(hint.dot(x_axis)))
1372        .normalized()
1373        .map(|y_axis| (x_axis, y_axis))
1374        .map_err(|_| format!("{fn_name}: frame directions must not be parallel"))
1375}
1376
1377/// Trim-range validation shared by the conic constructors.  The knot span IS
1378/// the conic parameter range, so it must clear the knot identity band or the
1379/// resulting curve would fail knot validation with an unhelpful message.
1380fn conic_range(fn_name: &str, t0: f64, t1: f64) -> Result<(), String> {
1381    if !t0.is_finite() || !t1.is_finite() {
1382        return Err(format!("{fn_name}: parameter range must be finite"));
1383    }
1384    if t1 - t0 <= KNOT_IDENTITY_TOL {
1385        return Err(format!("{fn_name}: t1 ({t1}) must exceed t0 ({t0})"));
1386    }
1387    Ok(())
1388}
1389
1390/// Extreme trim parameters overflow the conic point formulas (cosh exceeds
1391/// f64 near |t| ~ 710, focal·t² near |t| ~ 1e150); surface that as the
1392/// constructor's own honest error instead of the generic NurbsCurve
1393/// finiteness rejection.
1394fn conic_points_finite(fn_name: &str, points: &[Vec4]) -> Result<(), String> {
1395    if points.iter().any(|point| {
1396        ![point.x, point.y, point.z, point.w]
1397            .iter()
1398            .all(|value| value.is_finite())
1399    }) {
1400        return Err(format!(
1401            "{fn_name}: control points overflow f64 — parameter range too extreme"
1402        ));
1403    }
1404    Ok(())
1405}
1406
1407/// Exact parabola segment y² = 4·focal·x in the local frame (vertex at the
1408/// origin, `axis` = +x, `latus_direction` = +y), parametrized the standard
1409/// way P(t) = (focal·t², 2·focal·t) and trimmed to t ∈ [t0, t1].  A parabola
1410/// segment is a plain quadratic polynomial in t (all weights 1), so a single
1411/// degree-2 Bézier over the knot span [t0, t1] reproduces both the point set
1412/// AND the parametrization exactly — no fitting, and evaluate(t) == P(t) for
1413/// every t, not just at the ends.
1414pub fn make_parabola(
1415    vertex: Vec3,
1416    axis: Vec3,
1417    latus_direction: Vec3,
1418    focal: f64,
1419    t0: f64,
1420    t1: f64,
1421) -> Result<NurbsCurve, String> {
1422    if !focal.is_finite() || focal <= EPS {
1423        return Err("make_parabola: focal distance must be positive".into());
1424    }
1425    conic_range("make_parabola", t0, t1)?;
1426    let (x_axis, y_axis) = conic_frame("make_parabola", axis, latus_direction)?;
1427    let point_at = |t: f64| {
1428        vertex
1429            .add(x_axis.scale(focal * t * t))
1430            .add(y_axis.scale(2.0 * focal * t))
1431    };
1432    // Bernstein middle point of the quadratic polynomial,
1433    // P(t0) + (t1−t0)/2·P'(t0), collapses to local (focal·t0·t1,
1434    // focal·(t0+t1)) — which is also the intersection of the two end
1435    // tangents, as it must be for any parabola segment.
1436    let middle = vertex
1437        .add(x_axis.scale(focal * t0 * t1))
1438        .add(y_axis.scale(focal * (t0 + t1)));
1439    let control_points = vec![
1440        Vec4::from_point(point_at(t0), 1.0),
1441        Vec4::from_point(middle, 1.0),
1442        Vec4::from_point(point_at(t1), 1.0),
1443    ];
1444    conic_points_finite("make_parabola", &control_points)?;
1445    NurbsCurve::new(2, vec![t0, t0, t0, t1, t1, t1], control_points)
1446}
1447
1448/// Exact arc of the hyperbola branch x²/a² − y²/b² = 1, x > 0 in the local
1449/// frame (center at the origin, `major_axis` = +x, `minor_axis` = +y),
1450/// parametrized P(t) = (a·cosh t, b·sinh t) and trimmed to t ∈ [t0, t1].
1451/// A single rational quadratic Bézier is exact for any sweep on one branch:
1452/// endpoints on the curve, middle control point at the intersection of the
1453/// end tangents, and middle weight cosh((t1−t0)/2) chosen so the shoulder
1454/// point lands back on the branch.  Only the point set is hyperbola-exact
1455/// away from the ends; the NURBS parameter coincides with the hyperbolic
1456/// parameter t exactly at t0, (t0+t1)/2 and t1.
1457pub fn make_hyperbola(
1458    center: Vec3,
1459    major_axis: Vec3,
1460    minor_axis: Vec3,
1461    a: f64,
1462    b: f64,
1463    t0: f64,
1464    t1: f64,
1465) -> Result<NurbsCurve, String> {
1466    if !a.is_finite() || a <= EPS {
1467        return Err("make_hyperbola: semi-axis a must be positive".into());
1468    }
1469    if !b.is_finite() || b <= EPS {
1470        return Err("make_hyperbola: semi-axis b must be positive".into());
1471    }
1472    conic_range("make_hyperbola", t0, t1)?;
1473    let (x_axis, y_axis) = conic_frame("make_hyperbola", major_axis, minor_axis)?;
1474    let point_at = |t: f64| {
1475        center
1476            .add(x_axis.scale(a * t.cosh()))
1477            .add(y_axis.scale(b * t.sinh()))
1478    };
1479    let mid = 0.5 * (t0 + t1);
1480    let middle_weight = (0.5 * (t1 - t0)).cosh();
1481    // In the scaled frame (x/a, y/b) the branch is the unit hyperbola and the
1482    // tangent at t is the line X·cosh t − Y·sinh t = 1; Cramer's rule on the
1483    // two end-tangent lines collapses their intersection to
1484    // P(mid)/cosh(half-sweep), so no explicit line-line solve is needed.
1485    // With this middle point, weight cosh(half-sweep) puts the shoulder point
1486    // (P0 + 2w·P1 + P2)/(2 + 2w) back on the branch at P(mid).
1487    let apex = center
1488        .add(x_axis.scale(a * mid.cosh() / middle_weight))
1489        .add(y_axis.scale(b * mid.sinh() / middle_weight));
1490    let control_points = vec![
1491        Vec4::from_point(point_at(t0), 1.0),
1492        Vec4::from_point(apex, middle_weight),
1493        Vec4::from_point(point_at(t1), 1.0),
1494    ];
1495    conic_points_finite("make_hyperbola", &control_points)?;
1496    NurbsCurve::new(2, vec![t0, t0, t0, t1, t1, t1], control_points)
1497}
1498
1499pub fn uniform_clamped_knots(
1500    control_point_count: usize,
1501    degree: usize,
1502) -> Result<Vec<f64>, String> {
1503    if control_point_count == 0 || control_point_count - 1 < degree {
1504        return Err("uniformClampedKnots: need at least degree+1 control points".into());
1505    }
1506    let n = control_point_count - 1;
1507    let mut knots = vec![0.0; degree + 1];
1508    let interior = n - degree;
1509    for index in 1..=interior {
1510        knots.push(index as f64 / (interior + 1) as f64);
1511    }
1512    knots.extend(std::iter::repeat_n(1.0, degree + 1));
1513    Ok(knots)
1514}
1515
1516fn binomial(n: usize, k: usize) -> f64 {
1517    if k > n {
1518        return 0.0;
1519    }
1520    let k = k.min(n - k);
1521    (1..=k).fold(1.0, |value, index| {
1522        value * (n - k + index) as f64 / index as f64
1523    })
1524}
1525
1526
1527
1528
1529/// SMALL CURVE DERIVATIVES (lane O row 21, 2026-10-29; on by default since
1530/// the batch 5 promotion, `BREP_CURVE_SMALL_DERIVATIVES=0` reads the heap): `NurbsCurve::derivatives`
1531/// up to second order is read from the stack twin
1532/// [`NurbsCurve::derivatives_small`] (one allocation for the result instead of
1533/// two), and the hot callers — point-in-face's nearest-curve refinement, the
1534/// imprint's parameter tolerance and its pierce tangents — read the twin
1535/// directly. Same arithmetic, same bits.
1536pub(crate) fn small_curve_derivatives_enabled() -> bool {
1537    switch!("BREP_CURVE_SMALL_DERIVATIVES", true)
1538}
1539
1540
1541
1542/// Piegl–Tiller A5.6 with caller-selected homogeneous arithmetic.
1543pub(crate) fn bezier_decompose_raw<T: Copy>(knots: &[f64], points: &[T], degree: usize, zero: T, blend: impl Fn(T, T, f64) -> T) -> Option<Vec<Vec<T>>> {
1544    let p = degree;
1545    let n = points.len();
1546    if p == 0 || n < p + 1 || knots.len() != n + p + 1 || knots.iter().any(|k| !k.is_finite()) || knots.windows(2).any(|w| w[1] < w[0]) {
1547        return None;
1548    }
1549    let (a, b) = (knots[0], knots[knots.len() - 1]);
1550    if !(b > a) || knots[..=p].iter().any(|k| *k != a) || knots[knots.len() - p - 1..].iter().any(|k| *k != b) {
1551        return None;
1552    }
1553    let m = knots.len() - 1;
1554    let (mut a, mut b) = (p, p + 1);
1555    let mut spans = Vec::with_capacity(n);
1556    let mut span: Vec<T> = points[..=p].to_vec();
1557    let mut alphas = vec![0.; p];
1558    while b < m {
1559        let first = b;
1560        while b < m && knots[b + 1] == knots[b] {
1561            b += 1;
1562        }
1563        let mult = b - first + 1;
1564        if b < m && mult > p {
1565            return None;
1566        }
1567        let mut next = vec![zero; p + 1];
1568        if mult < p {
1569            let numer = knots[b] - knots[a];
1570            for j in (mult + 1..=p).rev() {
1571                alphas[j - mult - 1] = numer / (knots[a + j] - knots[a]);
1572            }
1573            let r = p - mult;
1574            for j in 1..=r {
1575                let s = mult + j;
1576                for k in (s..=p).rev() {
1577                    let alpha = alphas[k - s];
1578                    span[k] = blend(span[k], span[k - 1], alpha);
1579                }
1580                if b < m {
1581                    next[r - j] = span[p];
1582                }
1583            }
1584        }
1585        spans.push(span);
1586        if b >= m {
1587            break;
1588        }
1589        for i in p - mult..=p {
1590            next[i] = points[b - p + i];
1591        }
1592        span = next;
1593        a = b;
1594        b += 1;
1595    }
1596    Some(spans)
1597}
1598
1599/// Homogeneous Bezier spans; callers requiring a particular certificate arithmetic
1600/// path may use `bezier_decompose_raw` or retain repeated insertion.
1601pub(crate) fn bezier_decompose(curve: &NurbsCurve) -> Option<Vec<Vec<Vec4>>> {
1602    bezier_decompose_raw(&curve.knots, &curve.control_points, curve.degree,
1603        Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 0.0 },
1604        |a, b, alpha| a.scale(alpha).add(b.scale(1.0 - alpha)))
1605}
1606
1607/// One raw Boehm insertion. Span selection and the denominator policy belong
1608/// to the caller, as does the interpolation expression's operand order.
1609pub(crate) fn boehm_insert_raw<T: Copy>(
1610    points: &[T], knots: &[f64], degree: usize, parameter: f64, span: usize,
1611    blend: impl Fn(T, T, f64, f64) -> T,
1612) -> (Vec<T>, Vec<f64>) {
1613    let mut out = Vec::with_capacity(points.len() + 1);
1614    out.extend_from_slice(&points[..=span - degree]);
1615    for i in span - degree + 1..=span {
1616        out.push(blend(points[i - 1], points[i], parameter - knots[i], knots[i + degree] - knots[i]));
1617    }
1618    out.extend_from_slice(&points[span..]);
1619    let mut fresh_knots = knots.to_vec();
1620    fresh_knots.insert(span + 1, parameter);
1621    (out, fresh_knots)
1622}
1623
1624
1625/// Heap basis for raw/unclamped knots or degrees beyond the stack specialization.
1626pub(crate) fn basis_functions_raw(knots: &[f64], degree: usize, span: usize, parameter: f64, guard: f64) -> Vec<f64> {
1627    let mut basis = vec![0.0; degree + 1];
1628    let mut left = vec![0.0; degree + 1];
1629    let mut right = vec![0.0; degree + 1];
1630    basis_functions_raw_into(knots, degree, span, parameter, guard, &mut basis, &mut left, &mut right);
1631    basis
1632}
1633
1634#[inline]
1635pub(crate) fn basis_functions_raw_into(knots: &[f64], degree: usize, span: usize, parameter: f64,
1636    guard: f64, basis: &mut [f64], left: &mut [f64], right: &mut [f64]) {
1637        let _reader_part = crate::reader_profile::Part::open(crate::reader_profile::BASIS);
1638    basis[0] = 1.0;
1639    for j in 1..=degree {
1640        left[j] = parameter - knots[span + 1 - j];
1641        right[j] = knots[span + j] - parameter;
1642        let mut saved = 0.0;
1643        for r in 0..j {
1644            let denominator = right[r + 1] + left[j - r];
1645            let temporary = if denominator.abs() <= guard {
1646                0.0
1647            } else {
1648                basis[r] / denominator
1649            };
1650            basis[r] = saved + right[r + 1] * temporary;
1651            saved = left[j - r] * temporary;
1652        }
1653        basis[j] = saved;
1654    }
1655}