Coverage Report

Created: 2025-07-23 06:50

/rust/registry/src/index.crates.io-6f17d22bba15001f/kurbo-0.11.3/src/offset.rs
Line
Count
Source (jump to first uncovered line)
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
0
    pub fn new(c: CubicBez, d: f64) -> Self {
63
0
        let q = c.deriv();
64
0
        let d0 = q.p0.to_vec2();
65
0
        let d1 = 2.0 * (q.p1 - q.p0);
66
0
        let d2 = q.p0.to_vec2() - 2.0 * q.p1.to_vec2() + q.p2.to_vec2();
67
0
        CubicOffset {
68
0
            c,
69
0
            q,
70
0
            d,
71
0
            c0: d * d1.cross(d0),
72
0
            c1: d * 2.0 * d2.cross(d0),
73
0
            c2: d * d2.cross(d1),
74
0
        }
75
0
    }
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
0
    pub fn new_regularized(c: CubicBez, d: f64, dimension: f64) -> Self {
82
0
        Self::new(c.regularize(dimension), d)
83
0
    }
84
85
0
    fn eval_offset(&self, t: f64) -> Vec2 {
86
0
        let dp = self.q.eval(t).to_vec2();
87
0
        let norm = Vec2::new(-dp.y, dp.x);
88
0
        // TODO: deal with hypot = 0
89
0
        norm * self.d / dp.hypot()
90
0
    }
91
92
0
    fn eval(&self, t: f64) -> Point {
93
0
        // Point on source curve.
94
0
        self.c.eval(t) + self.eval_offset(t)
95
0
    }
96
97
    /// Evaluate derivative of curve.
98
0
    fn eval_deriv(&self, t: f64) -> Vec2 {
99
0
        self.cusp_sign(t) * self.q.eval(t).to_vec2()
100
0
    }
101
102
    // Compute a function which has a zero-crossing at cusps, and is
103
    // positive at low curvatures on the source curve.
104
0
    fn cusp_sign(&self, t: f64) -> f64 {
105
0
        let ds2 = self.q.eval(t).to_vec2().hypot2();
106
0
        ((self.c2 * t + self.c1) * t + self.c0) / (ds2 * ds2.sqrt()) + 1.0
107
0
    }
108
}
109
110
impl ParamCurveFit for CubicOffset {
111
0
    fn sample_pt_tangent(&self, t: f64, sign: f64) -> CurveFitSample {
112
0
        let p = self.eval(t);
113
        const CUSP_EPS: f64 = 1e-8;
114
0
        let mut cusp = self.cusp_sign(t);
115
0
        if cusp.abs() < CUSP_EPS {
116
0
            // This is a numerical derivative, which is probably good enough
117
0
            // for all practical purposes, but an analytical derivative would
118
0
            // be more elegant.
119
0
            //
120
0
            // Also, we're not dealing with second or higher order cusps.
121
0
            cusp = sign * (self.cusp_sign(t + CUSP_EPS) - self.cusp_sign(t - CUSP_EPS));
122
0
        }
123
0
        let tangent = self.q.eval(t).to_vec2() * cusp.signum();
124
0
        CurveFitSample { p, tangent }
125
0
    }
126
127
0
    fn sample_pt_deriv(&self, t: f64) -> (Point, Vec2) {
128
0
        (self.eval(t), self.eval_deriv(t))
129
0
    }
130
131
0
    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
0
        let break_cusp_help = |mut x, mut d| {
136
0
            let mut cusp = self.cusp_sign(x);
137
0
            while cusp.abs() < CUSP_EPS && d < 1.0 {
138
0
                x += d;
139
0
                let old_cusp = cusp;
140
0
                cusp = self.cusp_sign(x);
141
0
                if cusp.abs() > old_cusp.abs() {
142
0
                    break;
143
0
                }
144
0
                d *= 2.0;
145
            }
146
0
            (x, cusp)
147
0
        };
148
0
        let (a, cusp0) = break_cusp_help(range.start, 1e-12);
149
0
        let (b, cusp1) = break_cusp_help(range.end, -1e-12);
150
0
        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
0
            return None;
154
0
        }
155
0
        let s = cusp1.signum();
156
0
        let f = |t| s * self.cusp_sign(t);
157
0
        let k1 = 0.2 / (b - a);
158
        const ITP_EPS: f64 = 1e-12;
159
0
        let x = solve_itp(f, a, b, ITP_EPS, 1, k1, s * cusp0, s * cusp1);
160
0
        Some(x)
161
0
    }
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
}