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}