Skip to main content

polycool/
poly_dyn.rs

1// Copyright 2025 the Kurbo Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Polynomials of dynamic (run-time) degree.
5
6use crate::{Cubic, Quadratic, different_signs, yuksel};
7
8/// A polynomial of dynamic degree.
9///
10/// It would be nice to have polynomials of type-level degree,
11/// but that's a bit awkward without const generic expressions
12/// (e.g. to express the type of the derivative). It could be
13/// done with `typenum` and `generic_array`...
14#[derive(Clone, Debug)]
15pub struct PolyDyn {
16    /// Coefficients in increasing order of degree.
17    ///
18    /// For example, `coeffs[0]` is the constant term.
19    coeffs: Vec<f64>,
20}
21
22impl<'a> std::ops::Mul<&'a PolyDyn> for &'a PolyDyn {
23    type Output = PolyDyn;
24
25    fn mul(self, rhs: &PolyDyn) -> PolyDyn {
26        let mut coeffs = vec![0.0; (self.coeffs.len() + rhs.coeffs.len()).saturating_sub(1)];
27
28        for (i, c) in self.coeffs.iter().enumerate() {
29            for (j, d) in rhs.coeffs.iter().enumerate() {
30                coeffs[i + j] += c * d;
31            }
32        }
33        PolyDyn { coeffs }
34    }
35}
36
37impl std::ops::Mul<&PolyDyn> for PolyDyn {
38    type Output = PolyDyn;
39
40    fn mul(self, rhs: &PolyDyn) -> PolyDyn {
41        (&self) * rhs
42    }
43}
44
45impl PolyDyn {
46    /// Constructs a new polynomial from coefficients.
47    ///
48    /// The first coefficient provided will be the constant term, the second will
49    /// be the linear term, and so on.
50    pub fn new(coeffs: impl IntoIterator<Item = f64>) -> Self {
51        PolyDyn {
52            coeffs: coeffs.into_iter().collect(),
53        }
54    }
55
56    /// The coefficients of this polynomial.
57    ///
58    /// In the returned slice, the coefficient of `x^i` is at index `i`.
59    pub fn coeffs(&self) -> &[f64] {
60        &self.coeffs
61    }
62
63    fn is_finite(&self) -> bool {
64        self.coeffs.iter().all(|c| c.is_finite())
65    }
66
67    /// Returns the polynomial that's the derivative of this polynomial.
68    pub fn deriv(&self) -> PolyDyn {
69        let mut coeffs = Vec::with_capacity(self.coeffs.len().saturating_sub(1));
70        // If we're empty (meaning that we're the constant zero polynomial),
71        // this will just return the zero polynomial again: no need for a
72        // special case.
73        for (i, c) in self.coeffs.iter().enumerate().skip(1) {
74            coeffs.push(c * (i as f64));
75        }
76        PolyDyn { coeffs }
77    }
78
79    /// Evaluates this polynomial at a point.
80    pub fn eval(&self, x: f64) -> f64 {
81        let mut ret = 0.0;
82        let mut x_pow = 1.0;
83        for &c in &self.coeffs {
84            ret += c * x_pow;
85            x_pow *= x;
86        }
87        ret
88    }
89
90    /// The degree of this polynomial.
91    ///
92    /// This function only looks at the *presence* of coefficients, not their
93    /// value. If you construct a polynomial with three coefficients, this
94    /// method will say that it has degree 2 even if all of those coefficients
95    /// are zero.
96    ///
97    /// A polynomial with no coefficients will give zero as its degree, as will
98    /// a polynomial with one coefficient.
99    pub fn degree(&self) -> usize {
100        self.coeffs.len().saturating_sub(1)
101    }
102
103    /// If this polynomial has degree 3 or less, converts it to a [cubic](crate::Cubic).
104    fn to_cubic(&self) -> Option<Cubic> {
105        if self.degree() <= 3 {
106            Some(Cubic::new([
107                self.coeffs.first().copied().unwrap_or(0.0),
108                self.coeffs.get(1).copied().unwrap_or(0.0),
109                self.coeffs.get(2).copied().unwrap_or(0.0),
110                self.coeffs.get(3).copied().unwrap_or(0.0),
111            ]))
112        } else {
113            None
114        }
115    }
116
117    /// If this polynomial has degree 2 or less, converts it to a [cubic](crate::Quadratic).
118    fn to_quadratic(&self) -> Option<Quadratic> {
119        if self.degree() <= 2 {
120            Some(Quadratic::new([
121                self.coeffs.first().copied().unwrap_or(0.0),
122                self.coeffs.get(1).copied().unwrap_or(0.0),
123                self.coeffs.get(2).copied().unwrap_or(0.0),
124            ]))
125        } else {
126            None
127        }
128    }
129
130    /// Finds all the roots in an interval, using Yuksel's algorithm.
131    ///
132    /// This is a numerical, iterative method. It first constructs critical
133    /// points to find bracketing intervals (intervals `[x0, x1]` where
134    /// `self.eval(x0)` and `self.eval(x1)` have different signs). Then it uses
135    /// a kind of modified Newton method to find a root on each bracketing
136    /// interval. It has a few limitations:
137    ///
138    /// - if there is only a small interval where the polynomial changes sign,
139    ///   it can miss roots. For example, when two roots are very close together
140    ///   it can miss them both.
141    /// - run time is quadratic in the degree. However, it is often very fast
142    ///   in practice for polynomials of low degree, especially if the interval
143    ///   `[lower, upper]` contains few roots.
144    pub fn roots_between(&self, lower: f64, upper: f64, x_error: f64) -> Vec<f64> {
145        let mut ret = Vec::new();
146        let mut scratch = Vec::new();
147        self.roots_between_with_buffer(lower, upper, x_error, &mut ret, &mut scratch);
148        ret
149    }
150
151    /// Finds all the roots in an interval, using Yuksel's algorithm.
152    ///
153    /// See [`PolyDyn::roots_between`] for more details. This method differs from that
154    /// one in that it performs fewer allocations: you provide an `out` buffer
155    /// for the result and a `scratch` buffer for intermediate computations.
156    pub fn roots_between_with_buffer(
157        &self,
158        lower: f64,
159        upper: f64,
160        x_error: f64,
161        out: &mut Vec<f64>,
162        scratch: &mut Vec<f64>,
163    ) {
164        out.clear();
165        scratch.clear();
166
167        if let Some(q) = self.to_quadratic() {
168            out.extend(q.roots().into_iter().filter(|&r| lower <= r && r <= upper));
169            return;
170        }
171        if let Some(c) = self.to_cubic() {
172            out.extend(c.roots_between(lower, upper, x_error));
173            return;
174        }
175
176        let deriv = self.deriv();
177        if !deriv.is_finite() {
178            return;
179        }
180        deriv.roots_between_with_buffer(lower, upper, x_error, scratch, out);
181        scratch.push(upper);
182        out.clear();
183        let mut last = lower;
184        let mut last_val = self.eval(last);
185
186        // `scratch` now contains all the critical points (in increasing order)
187        // and the upper endpoint of the interval. If we throw away all the
188        // critical points that are outside of (lower, upper), the things
189        // remaining in `scratch` are the endpoints of the potential bracketing
190        // intervals of our polynomial. So by filtering out uninteresting
191        // critical points, this loop is iterating over potential bracketing
192        // intervals.
193        for &mut x in scratch {
194            if x > last && x <= upper {
195                let val = self.eval(x);
196                if different_signs(last_val, val) {
197                    out.push(yuksel::find_root(
198                        |x| self.eval(x),
199                        |x| deriv.eval(x),
200                        last,
201                        x,
202                        last_val,
203                        val,
204                        x_error,
205                    ));
206                }
207
208                last = x;
209                last_val = val;
210            }
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn smoke() {
221        let x_minus_1 = PolyDyn::new([-1.0, 1.0]);
222        let x_minus_2 = PolyDyn::new([-2.0, 1.0]);
223        let x_minus_3 = PolyDyn::new([-3.0, 1.0]);
224        let x_minus_4 = PolyDyn::new([-4.0, 1.0]);
225
226        let p = &x_minus_1 * &x_minus_2 * &x_minus_3 * &x_minus_4;
227
228        let roots = p.roots_between(0.0, 5.0, 1e-6);
229        assert_eq!(roots.len(), 4);
230        assert!((roots[0] - 1.0).abs() <= 1e-6);
231        assert!((roots[1] - 2.0).abs() <= 1e-6);
232        assert!((roots[2] - 3.0).abs() <= 1e-6);
233        assert!((roots[3] - 4.0).abs() <= 1e-6);
234    }
235}