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, based on a curve fitting |
5 | //! approach. |
6 | //! |
7 | //! See the [Parallel curves of cubic Béziers] blog post for a discussion of how |
8 | //! this algorithm works and what kind of results can be expected. In general, it |
9 | //! is expected to perform much better than most published algorithms. The number |
10 | //! of curve segments needed to attain a given accuracy scales as O(n^6) with |
11 | //! accuracy. |
12 | //! |
13 | //! In general, to compute the offset curve (also known as parallel curve) of |
14 | //! a cubic Bézier segment, create a [`CubicOffset`] struct with the curve |
15 | //! segment and offset, then use [`fit_to_bezpath`] or [`fit_to_bezpath_opt`] |
16 | //! depending on how much time to spend optimizing the resulting path. |
17 | //! |
18 | //! [`fit_to_bezpath`]: crate::fit_to_bezpath |
19 | //! [`fit_to_bezpath_opt`]: crate::fit_to_bezpath_opt |
20 | //! [Parallel curves of cubic Béziers]: https://raphlinus.github.io/curves/2022/09/09/parallel-beziers.html |
21 | use core::ops::Range; |
22 | |
23 | #[cfg (not(feature = "std" ))] |
24 | use crate::common::FloatFuncs; |
25 | |
26 | use crate::{ |
27 | common::solve_itp, CubicBez, CurveFitSample, ParamCurve, ParamCurveDeriv, ParamCurveFit, Point, |
28 | QuadBez, Vec2, |
29 | }; |
30 | |
31 | /// The offset curve of a cubic Bézier. |
32 | /// |
33 | /// This is a representation of the offset curve of a cubic Bézier segment, for |
34 | /// purposes of curve fitting. |
35 | /// |
36 | /// See the [module-level documentation] for a bit more discussion of the approach, |
37 | /// and how this struct is to be used. |
38 | /// |
39 | /// [module-level documentation]: crate::offset |
40 | pub struct CubicOffset { |
41 | /// Source curve. |
42 | c: CubicBez, |
43 | /// Derivative of source curve. |
44 | q: QuadBez, |
45 | /// Offset. |
46 | d: f64, |
47 | // c0 + c1 t + c2 t^2 is the cross product of second and first |
48 | // derivatives of the underlying cubic, multiplied by offset (for |
49 | // computing cusp). |
50 | c0: f64, |
51 | c1: f64, |
52 | c2: f64, |
53 | } |
54 | |
55 | impl CubicOffset { |
56 | /// Create a new curve from Bézier segment and offset. |
57 | /// |
58 | /// This method should only be used if the Bézier is smooth. Use |
59 | /// [`new_regularized`] instead to deal with a wider range of inputs. |
60 | /// |
61 | /// [`new_regularized`]: Self::new_regularized |
62 | pub fn new(c: CubicBez, d: f64) -> Self { |
63 | let q = c.deriv(); |
64 | let d0 = q.p0.to_vec2(); |
65 | let d1 = 2.0 * (q.p1 - q.p0); |
66 | let d2 = q.p0.to_vec2() - 2.0 * q.p1.to_vec2() + q.p2.to_vec2(); |
67 | CubicOffset { |
68 | c, |
69 | q, |
70 | d, |
71 | c0: d * d1.cross(d0), |
72 | c1: d * 2.0 * d2.cross(d0), |
73 | c2: d * d2.cross(d1), |
74 | } |
75 | } |
76 | |
77 | /// Create a new curve from Bézier segment and offset, with numerical robustness tweaks. |
78 | /// |
79 | /// The dimension represents a minimum feature size; the regularization is allowed to |
80 | /// perturb the curve by this amount in order to improve the robustness. |
81 | pub fn new_regularized(c: CubicBez, d: f64, dimension: f64) -> Self { |
82 | Self::new(c.regularize(dimension), d) |
83 | } |
84 | |
85 | fn eval_offset(&self, t: f64) -> Vec2 { |
86 | let dp = self.q.eval(t).to_vec2(); |
87 | let norm = Vec2::new(-dp.y, dp.x); |
88 | // TODO: deal with hypot = 0 |
89 | norm * self.d / dp.hypot() |
90 | } |
91 | |
92 | fn eval(&self, t: f64) -> Point { |
93 | // Point on source curve. |
94 | self.c.eval(t) + self.eval_offset(t) |
95 | } |
96 | |
97 | /// Evaluate derivative of curve. |
98 | fn eval_deriv(&self, t: f64) -> Vec2 { |
99 | self.cusp_sign(t) * self.q.eval(t).to_vec2() |
100 | } |
101 | |
102 | // Compute a function which has a zero-crossing at cusps, and is |
103 | // positive at low curvatures on the source curve. |
104 | fn cusp_sign(&self, t: f64) -> f64 { |
105 | let ds2 = self.q.eval(t).to_vec2().hypot2(); |
106 | ((self.c2 * t + self.c1) * t + self.c0) / (ds2 * ds2.sqrt()) + 1.0 |
107 | } |
108 | } |
109 | |
110 | impl ParamCurveFit for CubicOffset { |
111 | fn sample_pt_tangent(&self, t: f64, sign: f64) -> CurveFitSample { |
112 | let p = self.eval(t); |
113 | const CUSP_EPS: f64 = 1e-8; |
114 | let mut cusp = self.cusp_sign(t); |
115 | if cusp.abs() < CUSP_EPS { |
116 | // This is a numerical derivative, which is probably good enough |
117 | // for all practical purposes, but an analytical derivative would |
118 | // be more elegant. |
119 | // |
120 | // Also, we're not dealing with second or higher order cusps. |
121 | cusp = sign * (self.cusp_sign(t + CUSP_EPS) - self.cusp_sign(t - CUSP_EPS)); |
122 | } |
123 | let tangent = self.q.eval(t).to_vec2() * cusp.signum(); |
124 | CurveFitSample { p, tangent } |
125 | } |
126 | |
127 | fn sample_pt_deriv(&self, t: f64) -> (Point, Vec2) { |
128 | (self.eval(t), self.eval_deriv(t)) |
129 | } |
130 | |
131 | fn break_cusp(&self, range: Range<f64>) -> Option<f64> { |
132 | const CUSP_EPS: f64 = 1e-8; |
133 | // When an endpoint is on (or very near) a cusp, move just far enough |
134 | // away from the cusp that we're confident we have the right sign. |
135 | let break_cusp_help = |mut x, mut d| { |
136 | let mut cusp = self.cusp_sign(x); |
137 | while cusp.abs() < CUSP_EPS && d < 1.0 { |
138 | x += d; |
139 | let old_cusp = cusp; |
140 | cusp = self.cusp_sign(x); |
141 | if cusp.abs() > old_cusp.abs() { |
142 | break; |
143 | } |
144 | d *= 2.0; |
145 | } |
146 | (x, cusp) |
147 | }; |
148 | let (a, cusp0) = break_cusp_help(range.start, 1e-12); |
149 | let (b, cusp1) = break_cusp_help(range.end, -1e-12); |
150 | if a >= b || cusp0 * cusp1 >= 0.0 { |
151 | // Discussion point: maybe we should search for double cusps in the interior |
152 | // of the range. |
153 | return None; |
154 | } |
155 | let s = cusp1.signum(); |
156 | let f = |t| s * self.cusp_sign(t); |
157 | let k1 = 0.2 / (b - a); |
158 | const ITP_EPS: f64 = 1e-12; |
159 | let x = solve_itp(f, a, b, ITP_EPS, 1, k1, s * cusp0, s * cusp1); |
160 | Some(x) |
161 | } |
162 | } |
163 | |
164 | #[cfg (test)] |
165 | mod tests { |
166 | use super::CubicOffset; |
167 | use crate::{fit_to_bezpath, fit_to_bezpath_opt, CubicBez, PathEl}; |
168 | |
169 | // This test tries combinations of parameters that have caused problems in the past. |
170 | #[test ] |
171 | fn pathological_curves() { |
172 | let curve = CubicBez { |
173 | p0: (-1236.3746269978635, 152.17981429574826).into(), |
174 | p1: (-1175.18662093517, 108.04721798590596).into(), |
175 | p2: (-1152.142883879584, 105.76260301083356).into(), |
176 | p3: (-1151.842639804639, 105.73040758939104).into(), |
177 | }; |
178 | let offset = 3603.7267536453924; |
179 | let accuracy = 0.1; |
180 | let offset_path = CubicOffset::new(curve, offset); |
181 | let path = fit_to_bezpath_opt(&offset_path, accuracy); |
182 | assert!(matches!(path.iter().next(), Some(PathEl::MoveTo(_)))); |
183 | let path_opt = fit_to_bezpath(&offset_path, accuracy); |
184 | assert!(matches!(path_opt.iter().next(), Some(PathEl::MoveTo(_)))); |
185 | } |
186 | |
187 | /// Cubic offset that used to trigger infinite recursion. |
188 | #[test ] |
189 | fn infinite_recursion() { |
190 | const DIM_TUNE: f64 = 0.25; |
191 | const TOLERANCE: f64 = 0.1; |
192 | let c = CubicBez::new( |
193 | (1096.2962962962963, 593.90243902439033), |
194 | (1043.6213991769548, 593.90243902439033), |
195 | (1030.4526748971193, 593.90243902439033), |
196 | (1056.7901234567901, 593.90243902439033), |
197 | ); |
198 | let co = CubicOffset::new_regularized(c, -0.5, DIM_TUNE * TOLERANCE); |
199 | fit_to_bezpath(&co, TOLERANCE); |
200 | } |
201 | |
202 | #[test ] |
203 | fn test_cubic_offset_simple_line() { |
204 | let cubic = CubicBez::new((0., 0.), (10., 0.), (20., 0.), (30., 0.)); |
205 | let offset = CubicOffset::new(cubic, 5.); |
206 | let _optimized = fit_to_bezpath(&offset, 1e-6); |
207 | } |
208 | } |
209 | |