kurbo/offset.rs
1// Copyright 2022 the Kurbo Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Computation of offset curves of cubic Béziers.
5//!
6//! The main algorithm in this module is a new technique designed for robustness
7//! and speed. The details are involved; hopefully there will be a paper.
8
9use arrayvec::ArrayVec;
10
11#[cfg(not(feature = "std"))]
12use crate::common::FloatFuncs;
13
14use crate::{
15    common::{solve_itp, solve_quadratic},
16    BezPath, CubicBez, ParamCurve, ParamCurveDeriv, PathSeg, Point, QuadBez, Vec2,
17};
18
19/// State used for computing an offset curve of a single cubic.
20struct CubicOffset {
21    /// The cubic being offset. This has been regularized.
22    c: CubicBez,
23    /// The derivative of `c`.
24    q: QuadBez,
25    /// The offset distance (same as the argument).
26    d: f64,
27    /// `c0 + c1 t + c2 t^2` is the cross product of second and first
28    /// derivatives of the underlying cubic, multiplied by the offset.
29    /// This is used for computing cusps on the offset curve.
30    ///
31    /// Note that given a curve `c(t)`, its signed curvature is
32    /// `c''(t) x c'(t) / ||c'(t)||^3`. See also [`Self::cusp_sign`].
33    c0: f64,
34    c1: f64,
35    c2: f64,
36    /// The tolerance (same as the argument).
37    tolerance: f64,
38}
39
40// We never let cusp values have an absolute value smaller than
41// this. When a cusp is found, determine its sign and use this value.
42const CUSP_EPSILON: f64 = 1e-12;
43
44/// Number of points for least-squares fit and error evaluation.
45///
46/// This value is a tradeoff between accuracy and performance. The main risk in it
47/// being to small is under-sampling the error and thus letting excessive error
48/// slip through. That said, the "arc drawing" approach is designed to be robust
49/// and not generate approximate results with narrow error peaks, even in near-cusp
50/// "J" shape curves.
51const N_LSE: usize = 8;
52
53/// The proportion of transverse error that is blended in the least-squares logic.
54const BLEND: f64 = 1e-3;
55
56/// Maximum recursion depth.
57///
58/// Recursion is bounded to this depth, so the total number of subdivisions will
59/// not exceed two to this power.
60///
61/// This is primarily a "belt and suspenders" robustness guard. In normal operation,
62/// the recursion bound should never be reached, as accuracy improves very quickly
63/// on subdivision. For unreasonably large coordinate values or small tolerances, it
64/// is possible, and in those cases the result will be out of tolerance.
65///
66/// Perhaps should be configurable.
67const MAX_DEPTH: usize = 8;
68
69/// State local to a subdivision
70struct OffsetRec {
71    t0: f64,
72    t1: f64,
73    // unit tangent at t0
74    utan0: Vec2,
75    // unit tangent at t1
76    utan1: Vec2,
77    cusp0: f64,
78    cusp1: f64,
79    /// Recursion depth
80    depth: usize,
81}
82
83/// Result of error evaluation
84struct ErrEval {
85    /// Maximum detected error
86    err_squared: f64,
87    /// Unit normals sampled uniformly across approximation
88    unorms: [Vec2; N_LSE],
89    /// Difference between point on source curve and normal from approximation.
90    err_vecs: [Vec2; N_LSE],
91}
92
93/// Result of subdivision
94struct SubdivisionPoint {
95    /// Source curve t value at subdivision point
96    t: f64,
97    /// Unit tangent at subdivision point
98    utan: Vec2,
99}
100
101/// Compute an approximate offset curve.
102///
103/// The parallel curve of `c` offset by `d` is written to the `result` path.
104///
105/// There is a fair amount of attention to robustness, but this method is not suitable
106/// for degenerate cubics with entirely co-linear control points. Those cases should be
107/// handled before calling this function, by replacing them with linear segments.
108pub fn offset_cubic(c: CubicBez, d: f64, tolerance: f64, result: &mut BezPath) {
109    result.truncate(0);
110    // A tuning parameter for regularization. A value too large may distort the curve,
111    // while a value too small may fail to generate smooth curves. This is a somewhat
112    // arbitrary value, and should be revisited.
113    const DIM_TUNE: f64 = 0.25;
114    // We use regularization to perturb the curve to avoid *interior* zero-derivative
115    // cusps. There is robustness logic in place to handle zero derivatives at the
116    // endpoints.
117    //
118    // As a performance note, it might be a good idea to move regularization and
119    // tangent determination to the caller, as those computations are the same for both
120    // signs of `d`.
121    let c_regularized = c.regularize_cusp(tolerance * DIM_TUNE);
122    let co = CubicOffset::new(c_regularized, d, tolerance);
123    let (tan0, tan1) = PathSeg::Cubic(c).tangents();
124    let utan0 = tan0.normalize();
125    let utan1 = tan1.normalize();
126    let cusp0 = co.endpoint_cusp(co.q.p0, co.c0);
127    let cusp1 = co.endpoint_cusp(co.q.p2, co.c0 + co.c1 + co.c2);
128    result.move_to(c.p0 + d * utan0.turn_90());
129    let rec = OffsetRec::new(0., 1., utan0, utan1, cusp0, cusp1, 0);
130    co.offset_rec(&rec, result);
131}
132
133impl CubicOffset {
134    /// Create a new curve from Bézier segment and offset.
135    fn new(c: CubicBez, d: f64, tolerance: f64) -> Self {
136        let q = c.deriv();
137        let d2 = 2.0 * d;
138        let p1xp0 = q.p1.to_vec2().cross(q.p0.to_vec2());
139        let p2xp0 = q.p2.to_vec2().cross(q.p0.to_vec2());
140        let p2xp1 = q.p2.to_vec2().cross(q.p1.to_vec2());
141        CubicOffset {
142            c,
143            q,
144            d,
145            c0: d2 * p1xp0,
146            c1: d2 * (p2xp0 - 2.0 * p1xp0),
147            c2: d2 * (p2xp1 - p2xp0 + p1xp0),
148            tolerance,
149        }
150    }
151
152    /// Compute curvature of the source curve times offset plus 1.
153    ///
154    /// This quantity is called "cusp" because cusps appear in the offset curve
155    /// where this value crosses zero. This is based on the geometric property
156    /// that the offset curve has a cusp when the radius of curvature of the
157    /// source curve is equal to the offset curve's distance.
158    ///
159    /// Note: there is a potential division by zero when the derivative vanishes.
160    /// We avoid doing so for interior points by regularizing the cubic beforehand.
161    /// We avoid doing so for endpoints by calling `endpoint_cusp` instead.
162    fn cusp_sign(&self, t: f64) -> f64 {
163        let ds2 = self.q.eval(t).to_vec2().hypot2();
164        ((self.c2 * t + self.c1) * t + self.c0) / (ds2 * ds2.sqrt()) + 1.0
165    }
166
167    /// Compute cusp value of endpoint.
168    ///
169    /// This is a special case of [`Self::cusp_sign`]. For the start point, `tan` should be
170    /// the start point tangent and `y` should be `c0`. For the end point, `tan` should be
171    /// the end point tangent and `y` should be `c0 + c1 + c2`.
172    ///
173    /// This is just evaluating the polynomial at t=0 and t=1.
174    ///
175    /// See [`Self::cusp_sign`] for a description of what "cusp value" means.
176    fn endpoint_cusp(&self, tan: Point, y: f64) -> f64 {
177        // Robustness to avoid divide-by-zero when derivatives vanish
178        const TAN_DIST_EPSILON: f64 = 1e-12;
179        let tan_dist = tan.to_vec2().hypot().max(TAN_DIST_EPSILON);
180        let rsqrt = 1.0 / tan_dist;
181        y * (rsqrt * rsqrt * rsqrt) + 1.0
182    }
183
184    /// Primary entry point for recursive subdivision.
185    ///
186    /// At a high level, this method determines whether subdivision is necessary. If
187    /// so, it determines a subdivision point and then recursively calls itself on
188    /// both subdivisions. If not, it computes a single cubic Bézier to approximate
189    /// the offset curve, and appends it to `result`.
190    fn offset_rec(&self, rec: &OffsetRec, result: &mut BezPath) {
191        // First, determine whether the offset curve contains a cusp. If the sign
192        // of the cusp value (curvature times offset plus 1) is different at the
193        // subdivision endpoints, then there is definitely a cusp inside. Find it and
194        // subdivide there.
195        //
196        // Note that there's a possibility the curve has two (or, potentially, any
197        // even number). We don't rigorously check for this case; if the measured
198        // error comes in under the tolerance, we simply accept it. Otherwise, in
199        // the common case we expect to detect a sign crossing from the new
200        // subdivision point.
201        if rec.cusp0 * rec.cusp1 < 0.0 {
202            let a = rec.t0;
203            let b = rec.t1;
204            let s = rec.cusp1.signum();
205            let f = |t| s * self.cusp_sign(t);
206            let k1 = 0.2 / (b - a);
207            const ITP_EPS: f64 = 1e-12;
208            let t = solve_itp(f, a, b, ITP_EPS, 1, k1, s * rec.cusp0, s * rec.cusp1);
209            // TODO(robustness): If we're unlucky, there will be 3 cusps between t0
210            // and t1, and the solver will land on the middle one. In that case, the
211            // derivative on cusp will be the opposite sign as expected.
212            //
213            // If we're even more unlucky, there is a second-order cusp, both zero
214            // cusp value and zero derivative.
215            let utan_t = self.q.eval(t).to_vec2().normalize();
216            let cusp_t_minus = CUSP_EPSILON.copysign(rec.cusp0);
217            let cusp_t_plus = CUSP_EPSILON.copysign(rec.cusp1);
218            self.subdivide(rec, result, t, utan_t, cusp_t_minus, cusp_t_plus);
219            return;
220        }
221        // We determine the first approximation to the offset curve.
222        let (mut a, mut b) = self.draw_arc(rec);
223        let dt = (rec.t1 - rec.t0) * (1.0 / (N_LSE + 1) as f64);
224        // These represent t values on the source curve.
225        let mut ts = core::array::from_fn(|i| rec.t0 + (i + 1) as f64 * dt);
226        let mut c_approx = self.apply(rec, a, b);
227        let err_init = self.eval_err(rec, c_approx, &mut ts);
228        let mut err = err_init;
229        // Number of least-squares refinement steps. More gives a smaller
230        // error, but takes more time.
231        const N_REFINE: usize = 2;
232        for _ in 0..N_REFINE {
233            if err.err_squared <= self.tolerance * self.tolerance {
234                break;
235            }
236            let (a2, b2) = self.refine_least_squares(rec, a, b, &err);
237            let c_approx2 = self.apply(rec, a2, b2);
238            let err2 = self.eval_err(rec, c_approx2, &mut ts);
239            if err2.err_squared >= err.err_squared {
240                break;
241            }
242            err = err2;
243            (a, b) = (a2, b2);
244            c_approx = c_approx2;
245        }
246        if rec.depth < MAX_DEPTH && err.err_squared > self.tolerance * self.tolerance {
247            let SubdivisionPoint { t, utan } = self.find_subdivision_point(rec);
248            // TODO(robustness): if cusp is extremely near zero, then assign epsilon
249            // with alternate signs based on derivative of cusp.
250            let cusp = self.cusp_sign(t);
251            self.subdivide(rec, result, t, utan, cusp, cusp);
252        } else {
253            result.curve_to(c_approx.p1, c_approx.p2, c_approx.p3);
254        }
255    }
256
257    /// Recursively subdivide.
258    ///
259    /// In the case of subdividing at a cusp, the cusp value at the subdivision point
260    /// is mathematically zero, but in those cases we treat it as a signed infinitesimal
261    /// value representing the values at t minus epsilon and t plus epsilon.
262    ///
263    /// Note that unit tangents are passed down explicitly. In the general case, they
264    /// are equal to the derivative (evaluated at that t value) normalized to unit
265    /// length, but in cases where the derivative is near-zero, they are computed more
266    /// robustly.
267    fn subdivide(
268        &self,
269        rec: &OffsetRec,
270        result: &mut BezPath,
271        t: f64,
272        utan_t: Vec2,
273        cusp_t_minus: f64,
274        cusp_t_plus: f64,
275    ) {
276        let rec0 = OffsetRec::new(
277            rec.t0,
278            t,
279            rec.utan0,
280            utan_t,
281            rec.cusp0,
282            cusp_t_minus,
283            rec.depth + 1,
284        );
285        self.offset_rec(&rec0, result);
286        let rec1 = OffsetRec::new(
287            t,
288            rec.t1,
289            utan_t,
290            rec.utan1,
291            cusp_t_plus,
292            rec.cusp1,
293            rec.depth + 1,
294        );
295        self.offset_rec(&rec1, result);
296    }
297
298    /// Convert from (a, b) parameter space to the approximate cubic Bézier.
299    ///
300    /// The offset approximation can be considered `B(t) + d * D(t)`, where `D(t)`
301    /// is roughly a unit vector in the direction of the unit normal of the source
302    /// curve. (The word "roughly" is appropriate because transverse error may
303    /// cancel out normal error, resulting in a lower error than either alone).
304    /// The endpoints of `D(t)` must be the unit normals of the source curve, and
305    /// the endpoint tangents of `D(t)` must tangent to the endpoint tangents of
306    /// the source curve, to ensure G1 continuity.
307    ///
308    /// The (a, b) parameters refer to the magnitude of the vector from the endpoint
309    /// to the corresponding control point in `D(t)`, the direction being determined
310    /// by the unit tangent.
311    ///
312    /// When the candidate solution would lead to negative distance from the
313    /// endpoint to the control point, that distance is clamped to zero. Otherwise
314    /// such solutions should be considered invalid, and have the unpleasant
315    /// property of sometimes passing error tolerance checks.
316    fn apply(&self, rec: &OffsetRec, a: f64, b: f64) -> CubicBez {
317        // wondering if p0 and p3 should be in rec
318        // Scale factor from derivatives to displacements
319        let s = (1. / 3.) * (rec.t1 - rec.t0);
320        let p0 = self.c.eval(rec.t0) + self.d * rec.utan0.turn_90();
321        let l0 = s * self.q.eval(rec.t0).to_vec2().length() + a * self.d;
322        let mut p1 = p0;
323        if l0 * rec.cusp0 > 0.0 {
324            p1 += l0 * rec.utan0;
325        }
326        let p3 = self.c.eval(rec.t1) + self.d * rec.utan1.turn_90();
327        let mut p2 = p3;
328        let l1 = s * self.q.eval(rec.t1).to_vec2().length() - b * self.d;
329        if l1 * rec.cusp1 > 0.0 {
330            p2 -= l1 * rec.utan1;
331        }
332        CubicBez::new(p0, p1, p2, p3)
333    }
334
335    /// Compute arc approximation.
336    ///
337    /// This is called "arc drawing" because if we just look at the delta
338    /// vector, it describes an arc from the initial unit normal to the final unit
339    /// normal, with "as smooth as possible" parametrization. This approximation
340    /// is not necessarily great, but is very robust, and in particular, accuracy
341    /// does not degrade for J shaped near-cusp source curves or when the offset
342    /// distance is large (with respect to the source curve arc length).
343    ///
344    /// It is a pretty good approximation overall and has very clean O(n^4) scaling.
345    /// Its worst performance is on curves with a large cubic component, where it
346    /// undershoots. The theory is that the least squares refinement improves those
347    /// cases.
348    fn draw_arc(&self, rec: &OffsetRec) -> (f64, f64) {
349        // possible optimization: this can probably be done with vectors
350        // rather than arctangent
351        let th = rec.utan1.cross(rec.utan0).atan2(rec.utan1.dot(rec.utan0));
352        let a = (2. / 3.) / (1.0 + (0.5 * th).cos()) * 2.0 * (0.5 * th).sin();
353        let b = -a;
354        (a, b)
355    }
356
357    /// Evaluate error and also refine t values.
358    ///
359    /// Returns evaluation of error including error vectors and (squared)
360    /// maximum error.
361    ///
362    /// The vector of t values represents points on the source curve; the logic
363    /// here is a Newton step to bring those points closer to the normal ray of
364    /// the approximation.
365    fn eval_err(&self, rec: &OffsetRec, c_approx: CubicBez, ts: &mut [f64; N_LSE]) -> ErrEval {
366        let qa = c_approx.deriv();
367        let mut err_squared = 0.0;
368        let mut unorms = [Vec2::ZERO; N_LSE];
369        let mut err_vecs = [Vec2::ZERO; N_LSE];
370        for i in 0..N_LSE {
371            let ta = (i + 1) as f64 * (1.0 / (N_LSE + 1) as f64);
372            let mut t = ts[i];
373            let p = self.c.eval(t);
374            // Newton step to refine t value
375            let pa = c_approx.eval(ta);
376            let tana = qa.eval(ta).to_vec2();
377            t += tana.dot(pa - p) / tana.dot(self.q.eval(t).to_vec2());
378            t = t.max(rec.t0).min(rec.t1);
379            ts[i] = t;
380            let cusp = rec.cusp0.signum();
381            let unorm = cusp * tana.normalize().turn_90();
382            unorms[i] = unorm;
383            let p_new = self.c.eval(t) + self.d * unorm;
384            let err_vec = pa - p_new;
385            err_vecs[i] = err_vec;
386            let mut dist_err_squared = err_vec.length_squared();
387            if !dist_err_squared.is_finite() {
388                // A hack to make sure we reject bad refinements
389                dist_err_squared = 1e12;
390            }
391            // Note: consider also incorporating angle error
392            err_squared = dist_err_squared.max(err_squared);
393        }
394        ErrEval {
395            err_squared,
396            unorms,
397            err_vecs,
398        }
399    }
400
401    /// Refine an approximation, minimizing least squares error.
402    ///
403    /// Compute the approximation that minimizes least squares error, based on the given error
404    /// evaluation.
405    ///
406    /// The effectiveness of this refinement varies. Basically, if the curve has a large cubic
407    /// component, then the arc drawing will undershoot systematically and this refinement will
408    /// reduce error considerably. In other cases, it will eventually converge to a local
409    /// minimum, but convergence is slow.
410    ///
411    /// The `BLEND` parameter controls a tradeoff between robustness and speed of convergence.
412    /// In the happy case, convergence is fast and not very sensitive to this parameter. If the
413    /// parameter is too small, then in near-parabola cases the determinant will be small and
414    /// the result not numerically stable.
415    ///
416    /// A value of 1.0 for `BLEND` corresponds to essentially the Hoschek method, minimizing
417    /// Euclidean distance (which tends to over-anchor on the given t values). A value of 0 would
418    /// minimize the dot product of error wrt the normal vector, ignoring the cross product
419    /// component.
420    ///
421    /// A possible future direction would be to tune the parameter adaptively.
422    fn refine_least_squares(&self, rec: &OffsetRec, a: f64, b: f64, err: &ErrEval) -> (f64, f64) {
423        let mut aa = 0.0;
424        let mut ab = 0.0;
425        let mut ac = 0.0;
426        let mut bb = 0.0;
427        let mut bc = 0.0;
428        for i in 0..N_LSE {
429            let n = err.unorms[i];
430            let err_vec = err.err_vecs[i];
431            let c_n = err_vec.dot(n);
432            let c_t = err_vec.cross(n);
433            let a_n = A_WEIGHTS[i] * rec.utan0.dot(n);
434            let a_t = A_WEIGHTS[i] * rec.utan0.cross(n);
435            let b_n = B_WEIGHTS[i] * rec.utan1.dot(n);
436            let b_t = B_WEIGHTS[i] * rec.utan1.cross(n);
437            aa += a_n * a_n + BLEND * (a_t * a_t);
438            ab += a_n * b_n + BLEND * a_t * b_t;
439            ac += a_n * c_n + BLEND * a_t * c_t;
440            bb += b_n * b_n + BLEND * (b_t * b_t);
441            bc += b_n * c_n + BLEND * b_t * c_t;
442        }
443        let idet = 1.0 / (self.d * (aa * bb - ab * ab));
444        let delta_a = idet * (ac * bb - ab * bc);
445        let delta_b = idet * (aa * bc - ac * ab);
446        (a - delta_a, b - delta_b)
447    }
448
449    /// Decide where to subdivide when error is exceeded.
450    ///
451    /// For curves not containing an inflection point, subdivide at the tangent
452    /// bisecting the endpoint tangents. The logic is that for a near cusp in the
453    /// source curve, you want the subdivided sections to be approximately
454    /// circular arcs with progressively smaller angles.
455    ///
456    /// When there is an inflection point (or, more specifically, when the curve
457    /// crosses its chord), bisecting the angle can lead to very lopsided arc
458    /// lengths, so just subdivide by t in that case.
459    fn find_subdivision_point(&self, rec: &OffsetRec) -> SubdivisionPoint {
460        let t = 0.5 * (rec.t0 + rec.t1);
461        let q_t = self.q.eval(t).to_vec2();
462        let x0 = rec.utan0.cross(q_t).abs();
463        let x1 = rec.utan1.cross(q_t).abs();
464        const SUBDIVIDE_THRESH: f64 = 0.1;
465        if x0 > SUBDIVIDE_THRESH * x1 && x1 > SUBDIVIDE_THRESH * x0 {
466            let utan = q_t.normalize();
467            return SubdivisionPoint { t, utan };
468        }
469
470        // Note: do we want to track p0 & p3 in rec, to avoid repeated eval?
471        let chord = self.c.eval(rec.t1) - self.c.eval(rec.t0);
472        if chord.cross(rec.utan0) * chord.cross(rec.utan1) < 0.0 {
473            let tan = rec.utan0 + rec.utan1;
474            if let Some(subdivision) =
475                self.subdivide_for_tangent(rec.utan0, rec.t0, rec.t1, tan, false)
476            {
477                return subdivision;
478            }
479        }
480        // Curve definitely has an inflection point
481        // Try to subdivide based on integral of absolute curvature.
482
483        // Tangents at recursion endpoints and inflection points.
484        let mut tangents: ArrayVec<Vec2, 4> = ArrayVec::new();
485        let mut ts: ArrayVec<f64, 4> = ArrayVec::new();
486        tangents.push(rec.utan0);
487        ts.push(rec.t0);
488        for t in self.c.inflections() {
489            if t > rec.t0 && t < rec.t1 {
490                tangents.push(self.q.eval(t).to_vec2());
491                ts.push(t);
492            }
493        }
494        tangents.push(rec.utan1);
495        ts.push(rec.t1);
496        let mut arc_angles: ArrayVec<f64, 3> = ArrayVec::new();
497        let mut sum = 0.0;
498        for i in 0..tangents.len() - 1 {
499            let tan0 = tangents[i];
500            let tan1 = tangents[i + 1];
501            let th = tan0.cross(tan1).atan2(tan0.dot(tan1));
502            sum += th.abs();
503            arc_angles.push(th);
504        }
505        let mut target = sum * 0.5;
506        let mut i = 0;
507        while arc_angles[i].abs() < target {
508            target -= arc_angles[i].abs();
509            i += 1;
510        }
511        let rotation = Vec2::from_angle(target.copysign(arc_angles[i]));
512        let base = tangents[i];
513        let tan = base.rotate_scale(rotation);
514        let utan0 = if i == 0 { rec.utan0 } else { base.normalize() };
515        self.subdivide_for_tangent(utan0, ts[i], ts[i + 1], tan, true)
516            .unwrap()
517    }
518
519    /// Find a subdivision point, given a tangent vector.
520    ///
521    /// When subdividing by bisecting the angle (or, more generally, subdividing by
522    /// the L1 norm of curvature when there are inflection points), we find the
523    /// subdivision point by solving for the tangent matching, specifically the
524    /// cross-product of the tangent and the curve's derivative being zero. For
525    /// internal cusps, subdividing near the cusp is a good thing, but there is
526    /// still a robustness concern for vanishing derivative at the endpoints.
527    fn subdivide_for_tangent(
528        &self,
529        utan0: Vec2,
530        t0: f64,
531        t1: f64,
532        tan: Vec2,
533        force: bool,
534    ) -> Option<SubdivisionPoint> {
535        let mut t = 0.0;
536        let mut n_soln = 0;
537        // set up quadratic equation for matching tangents
538        let z0 = tan.cross(self.q.p0.to_vec2());
539        let z1 = tan.cross(self.q.p1.to_vec2());
540        let z2 = tan.cross(self.q.p2.to_vec2());
541        let c0 = z0;
542        let c1 = 2.0 * (z1 - z0);
543        let c2 = (z2 - z1) - (z1 - z0);
544        for root in solve_quadratic(c0, c1, c2) {
545            if root >= t0 && root <= t1 {
546                t = root;
547                n_soln += 1;
548            }
549        }
550        if n_soln != 1 {
551            if !force {
552                return None;
553            }
554            // Numerical failure, try to subdivide at cusp; we pick the
555            // smaller derivative.
556            if self.q.eval(t0).to_vec2().length_squared()
557                > self.q.eval(t1).to_vec2().length_squared()
558            {
559                t = t1;
560            } else {
561                t = t0;
562            }
563        }
564        let q = self.q.eval(t).to_vec2();
565        const UTAN_EPSILON: f64 = 1e-12;
566        let utan = if n_soln == 1 && q.length_squared() >= UTAN_EPSILON {
567            q.normalize()
568        } else if tan.length_squared() >= UTAN_EPSILON {
569            // Curve has a zero-derivative cusp but angles well defined
570            tan.normalize()
571        } else {
572            // 180 degree U-turn, arbitrarily pick a direction.
573            // If we get to this point, there will probably be a failure.
574            utan0.turn_90()
575        };
576        Some(SubdivisionPoint { t, utan })
577    }
578}
579
580impl OffsetRec {
581    #[allow(clippy::too_many_arguments)]
582    fn new(
583        t0: f64,
584        t1: f64,
585        utan0: Vec2,
586        utan1: Vec2,
587        cusp0: f64,
588        cusp1: f64,
589        depth: usize,
590    ) -> Self {
591        OffsetRec {
592            t0,
593            t1,
594            utan0,
595            utan1,
596            cusp0,
597            cusp1,
598            depth,
599        }
600    }
601}
602
603/// Compute Bézier weights for evenly subdivided t values.
604const fn mk_a_weights(rev: bool) -> [f64; N_LSE] {
605    let mut result = [0.0; N_LSE];
606    let mut i = 0;
607    while i < N_LSE {
608        let t = (i + 1) as f64 / (N_LSE + 1) as f64;
609        let mt = 1. - t;
610        let ix = if rev { N_LSE - 1 - i } else { i };
611        result[ix] = 3.0 * mt * t * mt;
612        i += 1;
613    }
614    result
615}
616
617const A_WEIGHTS: [f64; N_LSE] = mk_a_weights(false);
618const B_WEIGHTS: [f64; N_LSE] = mk_a_weights(true);
619
620#[cfg(test)]
621mod tests {
622    use super::offset_cubic;
623    use crate::{BezPath, CubicBez, PathEl};
624
625    // This test tries combinations of parameters that have caused problems in the past.
626    #[test]
627    fn pathological_curves() {
628        let curve = CubicBez {
629            p0: (-1236.3746269978635, 152.17981429574826).into(),
630            p1: (-1175.18662093517, 108.04721798590596).into(),
631            p2: (-1152.142883879584, 105.76260301083356).into(),
632            p3: (-1151.842639804639, 105.73040758939104).into(),
633        };
634        let offset = 3603.7267536453924;
635        let accuracy = 0.1;
636
637        let mut result = BezPath::new();
638        offset_cubic(curve, offset, accuracy, &mut result);
639        assert!(matches!(result.iter().next(), Some(PathEl::MoveTo(_))));
640
641        let mut result = BezPath::new();
642        offset_cubic(curve, offset, accuracy, &mut result);
643        assert!(matches!(result.iter().next(), Some(PathEl::MoveTo(_))));
644    }
645
646    /// Cubic offset that used to trigger infinite recursion.
647    #[test]
648    fn infinite_recursion() {
649        const TOLERANCE: f64 = 0.1;
650        const OFFSET: f64 = -0.5;
651        let c = CubicBez::new(
652            (1096.2962962962963, 593.90243902439033),
653            (1043.6213991769548, 593.90243902439033),
654            (1030.4526748971193, 593.90243902439033),
655            (1056.7901234567901, 593.90243902439033),
656        );
657
658        let mut result = BezPath::new();
659        offset_cubic(c, OFFSET, TOLERANCE, &mut result);
660    }
661
662    #[test]
663    fn test_cubic_offset_simple_line() {
664        let cubic = CubicBez::new((0., 0.), (10., 0.), (20., 0.), (30., 0.));
665        let mut result = BezPath::new();
666        offset_cubic(cubic, 5., 1e-6, &mut result);
667    }
668}