axiolid_nurbs/
degree.rs

1//! Knot removal and degree operations (#33).
2//!
3//! # Which of these are exact, and which are not
4//!
5//! Degree ELEVATION is exact: every degree-p B-spline is also a degree-(p+1)
6//! B-spline, and the elevated control net represents the same curve. Nothing
7//! is approximated, so it always succeeds on valid input.
8//!
9//! Knot REMOVAL and degree REDUCTION are not. A knot is removable only if the
10//! curve is smooth enough there to be represented without it; a degree is
11//! reducible only if the curve was already representable at the lower degree.
12//! Neither is generally true, so both must either meet a stated tolerance or
13//! REFUSE.
14//!
15//! That is the whole design constraint here. An implementation that always
16//! returns something has silently approximated, and the caller cannot tell a
17//! clean removal from a lossy one. These return the deviation they actually
18//! introduced so the caller can check it against their own budget.
19
20use axiolid_contracts::{GeomError, GeomResult};
21use axiolid_core::{Point2, Point3, Scalar};
22use axiolid_curve::{BSplineCurve, BSplineCurve2, BSplineCurve3};
23use axiolid_evaluate::curve::{bspline_jet2, bspline_jet3};
24
25/// Outcome of a lossy operation: the curve, and the error it introduced.
26#[derive(Debug, Clone, PartialEq)]
27pub struct BoundedResult<C> {
28    /// The transformed curve.
29    pub curve: C,
30    /// Upper bound on the deviation from the original, in model units.
31    ///
32    /// Zero means the operation was exact. A caller comparing this against
33    /// its own budget is the intended use; the operation does not decide
34    /// whether the error is acceptable.
35    pub deviation_upper_bound: Scalar,
36}
37
38/// Raise a planar curve's degree by one without changing its image.
39///
40/// Exact: a degree-p curve is also a degree-(p+1) curve. Rational curves
41/// elevate in homogeneous coordinates, so weights are carried through rather
42/// than discarded.
43pub fn elevate_degree2(curve: &BSplineCurve2) -> GeomResult<BSplineCurve2> {
44    bspline_jet2(curve, 0.0)?;
45    let segments = crate::transform::bezier_segments2(curve)?;
46    let extract = |p: &Point2| [p.x, p.y];
47    let rebuild = |c: [Scalar; 2]| Point2::new(c[0], c[1]);
48    assemble(curve, &segments, extract, rebuild)
49}
50
51/// Raise a spatial curve's degree by one without changing its image.
52pub fn elevate_degree3(curve: &BSplineCurve3) -> GeomResult<BSplineCurve3> {
53    bspline_jet3(curve, 0.0)?;
54    let segments = crate::transform::bezier_segments3(curve)?;
55    let extract = |p: &Point3| [p.x, p.y, p.z];
56    let rebuild = |c: [Scalar; 3]| Point3::new(c[0], c[1], c[2]);
57    assemble(curve, &segments, extract, rebuild)
58}
59
60/// Degree elevation for a single clamped Bezier segment, in homogeneous form.
61///
62/// The standard identity: the elevated control points are the convex
63/// combination `(i/(p+1)) * P[i-1] + (1 - i/(p+1)) * P[i]`, with the endpoints
64/// carried through unchanged. Exact for polynomial and rational alike, because
65/// it is applied to homogeneous coordinates.
66fn elevate_bezier<const N: usize>(
67    points: &[[Scalar; N]],
68    weights: &[Scalar],
69) -> (Vec<[Scalar; N]>, Vec<Scalar>) {
70    let p = points.len() - 1;
71    let elevated = p + 2;
72    let mut out_points = Vec::with_capacity(elevated);
73    let mut out_weights = Vec::with_capacity(elevated);
74
75    out_points.push(points[0]);
76    out_weights.push(weights[0]);
77    for i in 1..=p {
78        let alpha = i as Scalar / (p as Scalar + 1.0);
79        let mut coordinate = [0.0; N];
80        for (axis, value) in coordinate.iter_mut().enumerate() {
81            // Homogeneous blend: weight the position by its own weight first.
82            *value = alpha * points[i - 1][axis] * weights[i - 1]
83                + (1.0 - alpha) * points[i][axis] * weights[i];
84        }
85        let weight = alpha * weights[i - 1] + (1.0 - alpha) * weights[i];
86        for value in &mut coordinate {
87            *value /= weight;
88        }
89        out_points.push(coordinate);
90        out_weights.push(weight);
91    }
92    out_points.push(points[p]);
93    out_weights.push(weights[p]);
94    (out_points, out_weights)
95}
96
97/// Assemble elevated Bezier segments into one clamped curve.
98///
99/// Each segment elevates exactly; joining them keeps Bezier form, so every
100/// internal knot carries multiplicity `q` for elevated degree `q`. The curve
101/// is identical to the input. The representation is deliberately not
102/// knot-minimal: collapsing the redundant internal knots is a lossy step
103/// (`remove_knot`), and folding it in here would hide an approximation inside
104/// an operation documented as exact.
105fn assemble<const N: usize, P: Clone>(
106    curve: &BSplineCurve<P>,
107    segments: &[BSplineCurve<P>],
108    coordinates: impl Fn(&P) -> [Scalar; N],
109    point: impl Fn([Scalar; N]) -> P,
110) -> GeomResult<BSplineCurve<P>> {
111    let elevated_degree = curve.degree.checked_add(1).ok_or_else(|| {
112        GeomError::InvalidInput("degree elevation would overflow the degree".to_owned())
113    })?;
114
115    let mut control_points: Vec<P> = Vec::new();
116    let mut weights: Vec<Scalar> = Vec::new();
117    for (index, segment) in segments.iter().enumerate() {
118        let points: Vec<[Scalar; N]> = segment.control_points.iter().map(&coordinates).collect();
119        let segment_weights = segment
120            .weights
121            .clone()
122            .unwrap_or_else(|| vec![1.0; points.len()]);
123        let (elevated_points, elevated_weights) = elevate_bezier(&points, &segment_weights);
124
125        // Adjacent segments share their join point; keep one copy.
126        let skip = usize::from(index > 0);
127        for (coordinate, weight) in elevated_points.into_iter().zip(elevated_weights).skip(skip) {
128            control_points.push(point(coordinate));
129            weights.push(weight);
130        }
131    }
132
133    // Each Bezier segment is clamped, so its first and last knot values are
134    // its domain ends. Boundary knots get multiplicity q+1 (clamped), internal
135    // joins get q, which is Bezier form at the elevated degree.
136    let clamped = u32::from(elevated_degree) + 1;
137    let internal = u32::from(elevated_degree);
138    let mut knots: Vec<Scalar> = Vec::with_capacity(segments.len() + 1);
139    let mut multiplicities: Vec<u32> = Vec::with_capacity(segments.len() + 1);
140
141    for (index, segment) in segments.iter().enumerate() {
142        let first = *segment
143            .knots
144            .first()
145            .ok_or_else(|| GeomError::InvalidInput("bezier segment has no knots".to_owned()))?;
146        if index == 0 {
147            knots.push(first);
148            multiplicities.push(clamped);
149        }
150        let last = *segment
151            .knots
152            .last()
153            .ok_or_else(|| GeomError::InvalidInput("bezier segment has no knots".to_owned()))?;
154        knots.push(last);
155        multiplicities.push(if index + 1 == segments.len() {
156            clamped
157        } else {
158            internal
159        });
160    }
161
162    // Preserve rationality: a polynomial input stays polynomial rather than
163    // acquiring a vector of ones, which would change the representation
164    // without changing the curve.
165    let weights = curve.weights.as_ref().map(|_| weights);
166
167    Ok(BSplineCurve {
168        degree: elevated_degree,
169        control_points,
170        knots,
171        multiplicities,
172        weights,
173        knot_spec: curve.knot_spec,
174        closed: curve.closed,
175        self_intersect: curve.self_intersect,
176    })
177}
178
179/// Remove one interior knot from a planar curve, or refuse.
180///
181/// Removal is lossy in general: a knot can only be dropped if the curve is
182/// already smooth enough there to be represented without it. This computes the
183/// candidate, MEASURES how far it actually moved, and refuses when that
184/// exceeds `tolerance`.
185///
186/// Measuring rather than trusting the recurrence is deliberate. The removal
187/// equations are an inverse of knot insertion and are only valid when the knot
188/// is genuinely removable; applying them to a knot that carries real shape
189/// produces a curve that looks plausible and is wrong. Sampling the result
190/// against the original turns that into a refusal instead.
191pub fn remove_knot2(
192    curve: &BSplineCurve2,
193    parameter: Scalar,
194    tolerance: Scalar,
195) -> GeomResult<BoundedResult<BSplineCurve2>> {
196    bspline_jet2(curve, parameter)?;
197    let candidate = remove(
198        curve,
199        parameter,
200        |p| [p.x, p.y],
201        |c| Point2::new(c[0], c[1]),
202    )?;
203    let deviation = deviation2(curve, &candidate)?;
204    accept(candidate, deviation, tolerance)
205}
206
207/// Remove one interior knot from a spatial curve, or refuse.
208pub fn remove_knot3(
209    curve: &BSplineCurve3,
210    parameter: Scalar,
211    tolerance: Scalar,
212) -> GeomResult<BoundedResult<BSplineCurve3>> {
213    bspline_jet3(curve, parameter)?;
214    let candidate = remove(
215        curve,
216        parameter,
217        |p| [p.x, p.y, p.z],
218        |c| Point3::new(c[0], c[1], c[2]),
219    )?;
220    let deviation = deviation3(curve, &candidate)?;
221    accept(candidate, deviation, tolerance)
222}
223
224/// Accept a lossy result only if it met the caller's tolerance.
225fn accept<C>(curve: C, deviation: Scalar, tolerance: Scalar) -> GeomResult<BoundedResult<C>> {
226    if !tolerance.is_finite() || tolerance < 0.0 {
227        return Err(GeomError::InvalidInput(
228            "tolerance must be finite and non-negative".to_owned(),
229        ));
230    }
231    if deviation > tolerance {
232        return Err(GeomError::Degenerate(format!(
233            "knot is not removable within tolerance: deviation {deviation:.3e} exceeds {tolerance:.3e}"
234        )));
235    }
236    Ok(BoundedResult {
237        curve,
238        deviation_upper_bound: deviation,
239    })
240}
241
242/// Samples used to measure how far a lossy result moved.
243///
244/// Dense enough to catch the local bulge a bad removal introduces, and fixed
245/// so the measurement is reproducible rather than depending on curve size.
246const DEVIATION_SAMPLES: usize = 128;
247
248fn deviation2(original: &BSplineCurve2, candidate: &BSplineCurve2) -> GeomResult<Scalar> {
249    let (lo, hi) = domain(original);
250    let mut worst: Scalar = 0.0;
251    for index in 0..=DEVIATION_SAMPLES {
252        let t = lo + (hi - lo) * (index as Scalar / DEVIATION_SAMPLES as Scalar);
253        let a = bspline_jet2(original, t)?.point;
254        let b = bspline_jet2(candidate, t)?.point;
255        worst = worst.max((a - b).length());
256    }
257    Ok(worst)
258}
259
260fn deviation3(original: &BSplineCurve3, candidate: &BSplineCurve3) -> GeomResult<Scalar> {
261    let (lo, hi) = domain(original);
262    let mut worst: Scalar = 0.0;
263    for index in 0..=DEVIATION_SAMPLES {
264        let t = lo + (hi - lo) * (index as Scalar / DEVIATION_SAMPLES as Scalar);
265        let a = bspline_jet3(original, t)?.point;
266        let b = bspline_jet3(candidate, t)?.point;
267        worst = worst.max((a - b).length());
268    }
269    Ok(worst)
270}
271
272/// Active parameter domain of a clamped curve.
273fn domain<P>(curve: &BSplineCurve<P>) -> (Scalar, Scalar) {
274    let mut expanded = Vec::new();
275    for (&knot, &multiplicity) in curve.knots.iter().zip(&curve.multiplicities) {
276        expanded.extend(core::iter::repeat_n(knot, multiplicity as usize));
277    }
278    let lo = expanded[usize::from(curve.degree)];
279    let hi = expanded[curve.control_points.len()];
280    (lo, hi)
281}
282
283/// Drop one knot by inverting the insertion recurrence.
284///
285/// Insertion computes new control points as convex combinations of old ones;
286/// removal walks that backwards from both ends of the affected span. When the
287/// knot is genuinely removable the two walks meet; when it is not, they
288/// disagree and the resulting curve differs from the original -- which is what
289/// the caller-side deviation check detects.
290pub(crate) fn remove<const N: usize, P: Clone>(
291    curve: &BSplineCurve<P>,
292    parameter: Scalar,
293    coordinates: impl Fn(&P) -> [Scalar; N],
294    point: impl Fn([Scalar; N]) -> P,
295) -> GeomResult<BSplineCurve<P>> {
296    if !parameter.is_finite() {
297        return Err(GeomError::InvalidInput(
298            "knot parameter must be finite".to_owned(),
299        ));
300    }
301    if curve.weights.is_some() {
302        return Err(GeomError::Unsupported {
303            backend: axiolid_contracts::BackendId::new("nurbs"),
304            operation: axiolid_contracts::Operation::CurveEvaluation,
305        });
306    }
307
308    let index = curve
309        .knots
310        .iter()
311        .position(|&k| k == parameter)
312        .ok_or_else(|| GeomError::InvalidInput("knot is not present in the curve".to_owned()))?;
313    if index == 0 || index + 1 == curve.knots.len() {
314        return Err(GeomError::InvalidInput(
315            "endpoint knots bound the domain and cannot be removed".to_owned(),
316        ));
317    }
318
319    let degree = usize::from(curve.degree);
320    let expanded = expand_local(curve);
321    // Last expanded position of this knot value.
322    let span = expanded
323        .iter()
324        .rposition(|&k| k == parameter)
325        .ok_or_else(|| GeomError::InvalidInput("knot vanished during expansion".to_owned()))?;
326    let multiplicity = curve.multiplicities[index] as usize;
327
328    // Piegl & Tiller A5.8. `temp` holds the recomputed run: index 0 and
329    // index `last + 1 - first` are seeded from the untouched neighbours, and
330    // the two walks meet in the middle.
331    let ord = degree + 1;
332    let first = span - degree;
333    let last = span - multiplicity;
334    let points: Vec<[Scalar; N]> = curve.control_points.iter().map(&coordinates).collect();
335
336    let mut temp: Vec<[Scalar; N]> = vec![[0.0; N]; last + 2 - first];
337    temp[0] = points[first - 1];
338    temp[last + 1 - first] = points[last + 1];
339
340    let (mut i, mut j) = (first, last);
341    let (mut ii, mut jj) = (1_usize, last - first);
342    while j > i {
343        let alfi = (parameter - expanded[i]) / (expanded[i + ord] - expanded[i]);
344        let alfj = (parameter - expanded[j]) / (expanded[j + ord] - expanded[j]);
345        for axis in 0..N {
346            temp[ii][axis] = (points[i][axis] - (1.0 - alfi) * temp[ii - 1][axis]) / alfi;
347            temp[jj][axis] = (points[j][axis] - alfj * temp[jj + 1][axis]) / (1.0 - alfj);
348        }
349        i += 1;
350        ii += 1;
351        j -= 1;
352        jj -= 1;
353    }
354
355    // Write the recomputed run back, then drop the surplus point. The
356    // meeting point (j == i) needs its own store: the loop above stops
357    // before writing it, and omitting it leaves one stale control point.
358    let mut result = points.clone();
359    let (mut i, mut j) = (first, last);
360    while j > i {
361        result[i] = temp[i - first + 1];
362        result[j] = temp[j - first + 1];
363        i += 1;
364        j -= 1;
365    }
366    if j == i {
367        result[i] = temp[i - first + 1];
368    }
369    result.remove(last);
370    let points = result;
371
372    let mut knots = curve.knots.clone();
373    let mut multiplicities = curve.multiplicities.clone();
374    multiplicities[index] -= 1;
375    if multiplicities[index] == 0 {
376        knots.remove(index);
377        multiplicities.remove(index);
378    }
379
380    Ok(BSplineCurve {
381        degree: curve.degree,
382        control_points: points.into_iter().map(&point).collect(),
383        knots,
384        multiplicities,
385        weights: None,
386        knot_spec: curve.knot_spec,
387        closed: curve.closed,
388        self_intersect: curve.self_intersect,
389    })
390}
391
392fn expand_local<P>(curve: &BSplineCurve<P>) -> Vec<Scalar> {
393    let mut expanded = Vec::new();
394    for (&knot, &multiplicity) in curve.knots.iter().zip(&curve.multiplicities) {
395        expanded.extend(core::iter::repeat_n(knot, multiplicity as usize));
396    }
397    expanded
398}
399
400/// Lower a planar curve's degree by one, or refuse.
401///
402/// Lossy in general: only a curve that was already representable at the lower
403/// degree reduces cleanly. An elevated curve is the clean case, and reduction
404/// recovers what it started from.
405///
406/// Same discipline as knot removal: compute, measure, and refuse when the
407/// deviation exceeds `tolerance`, rather than trusting the reduction formula
408/// on a curve that genuinely needs its degree.
409pub fn reduce_degree2(
410    curve: &BSplineCurve2,
411    tolerance: Scalar,
412) -> GeomResult<BoundedResult<BSplineCurve2>> {
413    bspline_jet2(curve, 0.0)?;
414    let segments = crate::transform::bezier_segments2(curve)?;
415    let candidate = reduce(
416        curve,
417        &segments,
418        |p: &Point2| [p.x, p.y],
419        |c: [Scalar; 2]| Point2::new(c[0], c[1]),
420    )?;
421    let deviation = deviation2(curve, &candidate)?;
422    accept(candidate, deviation, tolerance)
423}
424
425/// Lower a spatial curve's degree by one, or refuse.
426pub fn reduce_degree3(
427    curve: &BSplineCurve3,
428    tolerance: Scalar,
429) -> GeomResult<BoundedResult<BSplineCurve3>> {
430    bspline_jet3(curve, 0.0)?;
431    let segments = crate::transform::bezier_segments3(curve)?;
432    let candidate = reduce(
433        curve,
434        &segments,
435        |p: &Point3| [p.x, p.y, p.z],
436        |c: [Scalar; 3]| Point3::new(c[0], c[1], c[2]),
437    )?;
438    let deviation = deviation3(curve, &candidate)?;
439    accept(candidate, deviation, tolerance)
440}
441
442/// Degree reduction for one Bezier segment.
443///
444/// Forward and backward recurrences each reconstruct the lower-degree control
445/// points; averaging them distributes the error instead of piling it at one
446/// end, which is what a one-directional recurrence does.
447fn reduce_bezier<const N: usize>(points: &[[Scalar; N]]) -> Vec<[Scalar; N]> {
448    let p = points.len() - 1;
449    let reduced = p;
450    let mut forward: Vec<[Scalar; N]> = vec![[0.0; N]; reduced];
451    let mut backward: Vec<[Scalar; N]> = vec![[0.0; N]; reduced];
452
453    forward[0] = points[0];
454    for i in 1..reduced {
455        let alpha = i as Scalar / p as Scalar;
456        for axis in 0..N {
457            forward[i][axis] = (points[i][axis] - alpha * forward[i - 1][axis]) / (1.0 - alpha);
458        }
459    }
460
461    backward[reduced - 1] = points[p];
462    for i in (0..reduced - 1).rev() {
463        let alpha = (i + 1) as Scalar / p as Scalar;
464        for axis in 0..N {
465            backward[i][axis] =
466                (points[i + 1][axis] - (1.0 - alpha) * backward[i + 1][axis]) / alpha;
467        }
468    }
469
470    (0..reduced)
471        .map(|i| {
472            let mut blended = [0.0; N];
473            for (axis, value) in blended.iter_mut().enumerate() {
474                *value = 0.5 * (forward[i][axis] + backward[i][axis]);
475            }
476            blended
477        })
478        .collect()
479}
480
481/// Reduce each Bezier segment and rejoin, mirroring `assemble`.
482fn reduce<const N: usize, P: Clone>(
483    curve: &BSplineCurve<P>,
484    segments: &[BSplineCurve<P>],
485    coordinates: impl Fn(&P) -> [Scalar; N],
486    point: impl Fn([Scalar; N]) -> P,
487) -> GeomResult<BSplineCurve<P>> {
488    if curve.degree < 2 {
489        return Err(GeomError::InvalidInput(
490            "degree 1 cannot be reduced further and stay a curve".to_owned(),
491        ));
492    }
493    if curve.weights.is_some() {
494        return Err(GeomError::Unsupported {
495            backend: axiolid_contracts::BackendId::new("nurbs"),
496            operation: axiolid_contracts::Operation::CurveEvaluation,
497        });
498    }
499    let reduced_degree = curve.degree - 1;
500
501    let mut control_points: Vec<P> = Vec::new();
502    for (index, segment) in segments.iter().enumerate() {
503        let points: Vec<[Scalar; N]> = segment.control_points.iter().map(&coordinates).collect();
504        let lowered = reduce_bezier(&points);
505        let skip = usize::from(index > 0);
506        for coordinate in lowered.into_iter().skip(skip) {
507            control_points.push(point(coordinate));
508        }
509    }
510
511    let clamped = u32::from(reduced_degree) + 1;
512    let internal = u32::from(reduced_degree);
513    let mut knots: Vec<Scalar> = Vec::with_capacity(segments.len() + 1);
514    let mut multiplicities: Vec<u32> = Vec::with_capacity(segments.len() + 1);
515    for (index, segment) in segments.iter().enumerate() {
516        let first = *segment
517            .knots
518            .first()
519            .ok_or_else(|| GeomError::InvalidInput("bezier segment has no knots".to_owned()))?;
520        if index == 0 {
521            knots.push(first);
522            multiplicities.push(clamped);
523        }
524        let last = *segment
525            .knots
526            .last()
527            .ok_or_else(|| GeomError::InvalidInput("bezier segment has no knots".to_owned()))?;
528        knots.push(last);
529        multiplicities.push(if index + 1 == segments.len() {
530            clamped
531        } else {
532            internal
533        });
534    }
535
536    Ok(BSplineCurve {
537        degree: reduced_degree,
538        control_points,
539        knots,
540        multiplicities,
541        weights: None,
542        knot_spec: curve.knot_spec,
543        closed: curve.closed,
544        self_intersect: curve.self_intersect,
545    })
546}