Skip to main content

kurbo/
expand.rs

1// Copyright 2026 the Kurbo Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4use crate::{
5    Affine, Arc, BezPath, CubicBez, Join, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Point,
6    QuadBez, Shape, Vec2, bezpath::close_subpaths, segments,
7};
8
9#[cfg(not(feature = "std"))]
10use crate::common::FloatFuncs;
11
12/// A diagonal matrix, suitable for representing anisotropic scaling.
13#[derive(Clone, Copy, Debug, PartialEq)]
14#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16pub struct Diagonal2 {
17    /// The horizontal expansion factor.
18    pub xx: f64,
19    /// The vertical expansion factor.
20    pub yy: f64,
21}
22
23struct ExpandCtx {
24    expand: Diagonal2,
25    join: Join,
26    miter_limit: f64,
27    tolerance: f64,
28    result: BezPath,
29    first_n: Option<Vec2>,
30    first_tan: Vec2,
31    last_pt: Point,
32    last_n: Option<Vec2>,
33    last_tan: Vec2,
34}
35
36struct CubicCtx {
37    q: QuadBez,
38    utan0: Vec2,
39    utan1: Vec2,
40}
41
42struct TwoPointSample {
43    a_n: f64,
44    b_n: f64,
45    c_n: f64,
46}
47
48impl Diagonal2 {
49    /// Create a diagonal matrix.
50    pub const fn new(xx: f64, yy: f64) -> Self {
51        Self { xx, yy }
52    }
53
54    /// Matrix inverse.
55    ///
56    /// Will of course produce infinities if a component is zero.
57    pub const fn inv(self) -> Self {
58        Diagonal2::new(1.0 / self.xx, 1.0 / self.yy)
59    }
60
61    /// Absolute value of transform components.
62    pub const fn abs(self) -> Self {
63        Self::new(self.xx.abs(), self.yy.abs())
64    }
65
66    /// Scale a normal vector.
67    ///
68    /// This is mathematically equivalent to `self * (self * n).normalize()`
69    /// for positive transforms, but handles zeros and gets the sign correct
70    /// when negative.
71    ///
72    /// Note that `n` need not be unit length.
73    pub fn scale_normal(self, n: Vec2) -> Vec2 {
74        let z = self * n;
75        let z_hypot2 = z.hypot2();
76        if z_hypot2 == 0.0 {
77            Vec2::ZERO
78        } else {
79            let inv_scale = 1.0 / z_hypot2.sqrt();
80            self.abs() * z * inv_scale
81        }
82    }
83}
84
85impl core::ops::Neg for Diagonal2 {
86    type Output = Self;
87
88    fn neg(self) -> Self {
89        Diagonal2::new(-self.xx, -self.yy)
90    }
91}
92
93impl core::ops::Mul<Vec2> for Diagonal2 {
94    type Output = Vec2;
95
96    fn mul(self, rhs: Vec2) -> Vec2 {
97        Vec2::new(self.xx * rhs.x, self.yy * rhs.y)
98    }
99}
100
101impl core::ops::Mul<Point> for Diagonal2 {
102    type Output = Point;
103
104    fn mul(self, rhs: Point) -> Point {
105        Point::new(self.xx * rhs.x, self.yy * rhs.y)
106    }
107}
108
109// Note: a bunch more ops on `Diagonal2` could be implemented, but for now we'll stick to what we need.
110
111/// Expand a path.
112///
113/// Expands a filled path by the expansion, which allows separate x and y factors. The
114/// path (and the result) is interpreted according to the nonzero winding rule. Both
115/// factors should be positive. A negative expansion will shrink the path but is also
116/// likely to leave intersection artifacts at corners.
117///
118/// The direction of the expansion is based on the signed area of the overall path.
119/// This should give expected results most of the time, but there are exceptions. For a
120/// figure-eight path, one lobe will be expanded and the other shrunk. Similarly if
121/// there are two disjoint subpaths with opposite winding.
122///
123/// The tolerance is mostly for joins and robustness; it is not used to guide
124/// subdivision. Rather, each Bézier segment in the input generally results in one
125/// cubic Bézier in the output. Thus, it is not expected to work well when the
126/// expansion factor is large compared with the radius of curvature on the input.
127pub fn expand_path(
128    path: impl Shape,
129    mut expand: Diagonal2,
130    join: Join,
131    miter_limit: f64,
132    tolerance: f64,
133) -> BezPath {
134    if segments(close_subpaths(path.path_elements(tolerance))).area() >= 0.0 {
135        expand = -expand;
136    }
137    expand_path_signed(path, expand, join, miter_limit, tolerance)
138}
139
140/// Expand a path when the sign is known.
141///
142/// Applies the expansion based on the path orientation, so that expansion happens
143/// with positive `expand` values on subpaths with negative area, or vice versa. This
144/// is backwards from the intuitive sign convention, but results from the choice of
145/// convention for the offset primitives.
146pub fn expand_path_signed(
147    path: impl Shape,
148    expand: Diagonal2,
149    join: Join,
150    miter_limit: f64,
151    tolerance: f64,
152) -> BezPath {
153    let result = BezPath::new();
154    let mut ctx = ExpandCtx {
155        expand,
156        join,
157        miter_limit,
158        tolerance,
159        result,
160        first_n: None,
161        first_tan: Vec2::default(),
162        last_pt: Point::default(),
163        last_n: None,
164        last_tan: Vec2::default(),
165    };
166    let mut first_pt = Point::default();
167    for el in path.path_elements(tolerance) {
168        match el {
169            PathEl::MoveTo(point) => {
170                ctx.do_close_path(first_pt);
171                first_pt = point;
172                ctx.last_pt = point;
173            }
174            PathEl::LineTo(p1) => ctx.do_line(p1),
175            PathEl::QuadTo(p1, p2) => ctx.do_quad(p1, p2),
176            PathEl::CurveTo(p1, p2, p3) => ctx.do_cubic(p1, p2, p3),
177            PathEl::ClosePath => ctx.do_close_path(first_pt),
178        }
179    }
180    // Treat all subpaths as closed; close if left open in input.
181    ctx.do_close_path(first_pt);
182    ctx.result
183}
184
185impl ExpandCtx {
186    /// Helper function to determine if a distance is within tolerance.
187    fn in_tolerance(&self, v: Vec2) -> bool {
188        v.hypot2() < self.tolerance * self.tolerance
189    }
190
191    /// Process a line segment. Includes initial join.
192    fn do_line(&mut self, p1: Point) {
193        if p1 == self.last_pt {
194            return;
195        }
196        let tan = p1 - self.last_pt;
197        let n = self.expand.scale_normal(tan.turn_90());
198        self.do_join(n, tan, true);
199        let out_p1 = p1 + n;
200        self.result.line_to(out_p1);
201        self.last_n = Some(n);
202        self.last_tan = tan;
203        self.last_pt = p1;
204    }
205
206    /// Process a quadratic Bézier segment. Includes initial join.
207    fn do_quad(&mut self, p1: Point, p2: Point) {
208        let q0 = p1 - self.last_pt;
209        let q1 = p2 - p1;
210        if self.in_tolerance(q0) || self.in_tolerance(q1) {
211            self.do_line(p2);
212            return;
213        }
214        let einv = self.expand.inv();
215        let utan0 = (einv * q0).normalize();
216        let utan1 = (einv * q1).normalize();
217        let det = utan1.cross(utan0);
218        if det.abs() < 1e-10 {
219            self.do_line(p2);
220            return;
221        }
222        let utanm = (einv * (p2 - self.last_pt)).normalize();
223        let n0 = self.expand.abs() * utan0.turn_90();
224        let n1 = self.expand.abs() * utan1.turn_90();
225        let mid_chord = self.last_pt.midpoint(p2);
226        let m = mid_chord.midpoint(p1) + self.expand.abs() * utanm.turn_90();
227        let out_p0 = self.last_pt + n0;
228        let out_p3 = p2 + n1;
229        let rhs = einv * (m - out_p0.midpoint(out_p3));
230        let idet = (8. / 3.) / det;
231        let a = (utan1.cross(rhs) * idet).max(0.0);
232        let b = (utan0.cross(rhs) * idet).max(0.0);
233        self.do_join(n0, q0, false);
234        let out_p1 = out_p0 + self.expand * (a * utan0);
235        let out_p2 = out_p3 - self.expand * (b * utan1);
236        self.result.curve_to(out_p1, out_p2, out_p3);
237        self.last_n = Some(n1);
238        self.last_tan = q1;
239        self.last_pt = p2;
240    }
241
242    /// Process a cubic segment. Includes initial join.
243    fn do_cubic(&mut self, p1: Point, p2: Point, p3: Point) {
244        let einv = self.expand.inv();
245        let c = CubicBez::new(einv * self.last_pt, einv * p1, einv * p2, einv * p3);
246        let (tan0, tan1) = PathSeg::Cubic(c).tangents();
247        let utan0 = tan0.normalize();
248        let utan1 = tan1.normalize();
249        let q = c.deriv();
250        let p1xp0 = q.p1.to_vec2().cross(q.p0.to_vec2());
251        let p2xp1 = q.p2.to_vec2().cross(q.p1.to_vec2());
252        let cx = CubicCtx { q, utan0, utan1 };
253        // First we try one-point, but only if there isn't one inflection point.
254        let mut soln = if p1xp0 * p2xp1 >= 0.0 {
255            try_one_point(&cx)
256        } else {
257            None
258        };
259        if soln.is_none() {
260            // We try two-point linear if we don't have a one-point solution. A
261            // more sophisticated approach would be to evaluate error and pick a
262            // minimum, but that would be more complexity and take time. This is
263            // very likely good enough for the purpose.
264            soln = two_point_linear(&cx);
265        }
266        if let Some((a, b)) = soln {
267            let n0 = self.expand.abs() * utan0.turn_90();
268            let n1 = self.expand.abs() * utan1.turn_90();
269            self.do_join(n0, self.expand * utan0, false);
270            let out_p3 = p3 + n1;
271            // TODO: clamp to correct direction
272            let out_p1 = p1 + n0 + self.expand.abs() * (a * utan0);
273            let out_p2 = p2 + n1 + self.expand.abs() * (b * utan1);
274            self.result.curve_to(out_p1, out_p2, out_p3);
275            self.last_n = Some(n1);
276            self.last_tan = self.expand * utan1;
277            self.last_pt = p3;
278        } else {
279            self.do_line(p3);
280        }
281    }
282
283    /// Do a join.
284    ///
285    /// The `tan` parameter is a vector tangent to the start of the new segment.
286    /// The `n` parameter is the normal vector (turned tangent) scaled by the expansion.
287    fn do_join(&mut self, n: Vec2, tan: Vec2, is_line: bool) {
288        // TODO: other join types etc
289        if let Some(last_n) = self.last_n {
290            let p = self.last_pt + n;
291            if !self.in_tolerance(n - last_n) {
292                if self.join != Join::Bevel {
293                    let cross = self.last_tan.cross(tan);
294                    if cross * self.expand.xx < 0.0 {
295                        match self.join {
296                            Join::Bevel => unreachable!(),
297                            Join::Miter => {
298                                let dot = self.last_tan.dot(tan);
299                                let hypot = cross.hypot(dot);
300                                if 2.0 * hypot < (hypot + dot) * self.miter_limit * self.miter_limit
301                                {
302                                    let h = (n - last_n).cross(tan) / self.last_tan.cross(tan);
303                                    let miter_pt = self.last_pt + last_n + h * self.last_tan;
304                                    // A cheap optimization to reduce line segments with joins to lines
305                                    if let Some(PathEl::LineTo(p)) =
306                                        self.result.elements_mut().last_mut()
307                                    {
308                                        *p = miter_pt;
309                                    } else {
310                                        self.result.line_to(miter_pt);
311                                    }
312                                    if is_line {
313                                        return;
314                                    }
315                                }
316                            }
317                            Join::Round => {
318                                // Cheaper inverse; everything is normalized so we don't care about uniform scaling.
319                                let einv = Diagonal2::new(self.expand.yy, self.expand.xx);
320                                let last_tann = einv * self.last_tan;
321                                let tann = einv * tan;
322                                let crossn = (last_tann).cross(tann);
323                                let dotn = (last_tann).dot(tann);
324                                let angle = crossn.atan2(dotn).abs();
325                                let nt = self.expand * tann.normalize();
326                                let a = Affine::new([
327                                    n.x,
328                                    n.y,
329                                    nt.x,
330                                    nt.y,
331                                    self.last_pt.x,
332                                    self.last_pt.y,
333                                ]);
334                                let arc: Arc =
335                                    Arc::new(Point::ORIGIN, (1.0, 1.0), -angle, angle, 0.0);
336                                let tolerance = self.tolerance / einv.xx.abs().max(einv.yy.abs());
337                                arc.to_cubic_beziers(tolerance, |p1, p2, p3| {
338                                    self.result.curve_to(a * p1, a * p2, a * p3);
339                                });
340                                return;
341                            }
342                        }
343                    }
344                }
345                // Bevel case
346                self.result.line_to(p);
347            }
348        } else {
349            self.result.move_to(self.last_pt + n);
350            self.first_n = Some(n);
351            self.first_tan = tan;
352        }
353    }
354
355    /// Close an open subpath if there is one; no-op if already closed.
356    fn do_close_path(&mut self, first_pt: Point) {
357        if let Some(first_n) = self.first_n.take() {
358            // could do this test inside do_line for all lines, but it already checks for 0-length
359            if first_pt.distance_squared(self.last_pt) > self.tolerance * self.tolerance {
360                self.do_line(first_pt);
361            }
362            self.do_join(first_n, self.first_tan, true);
363            self.result.close_path();
364        }
365        self.last_n = None;
366    }
367}
368
369// Note: here is our own copy of sophisticated cubic Bézier offset logic. We have
370// the ability to apply anisotropic expansion, but not cusp detection or subdivision,
371// and I've also stripped out all the error evaluation. At some point, we want to
372// redo stroking, and there may be an opportunity to share code.
373
374/// Try to compute one-point shape control for a cubic.
375///
376/// Result is (a, b) parameters.
377fn try_one_point(cx: &CubicCtx) -> Option<(f64, f64)> {
378    // TODO: possibly reduce duplication with quadratic case.
379    let tan = cx.q.eval(0.5).to_vec2();
380    let tan_hypot2 = tan.hypot2();
381    if tan_hypot2 < 1e-12 {
382        return None;
383    }
384    let utan = tan / tan_hypot2.sqrt();
385    let z = (utan - 0.5 * (cx.utan0 + cx.utan1)).turn_90();
386    let cross = cx.utan0.cross(cx.utan1);
387    if cross.abs() < 1e-12 {
388        return None;
389    }
390    let idet = (8. / 3.) / cross;
391    let a = z.cross(cx.utan1) * idet;
392    let b = cx.utan0.cross(z) * idet;
393    //let delta_tan = 0.75 * (b * cx.utan1 - a * cx.utan0) + 1.5 * (cx.utan1 - cx.utan0).turn_90();
394    //let angle_err = delta_tan.cross(utan);
395    //let err_est = 0.16 * angle_err.abs();
396    Some((a, b))
397}
398
399fn two_point_linear(cx: &CubicCtx) -> Option<(f64, f64)> {
400    const T0: f64 = 0.35;
401    let s = [T0, 1.0 - T0].map(|t| {
402        let utan = cx.q.eval(t).to_vec2().normalize();
403        let n = utan.turn_90();
404        let utan0_n = cx.utan0.dot(n);
405        let utan1_n = cx.utan1.dot(n);
406        let utan0xn = cx.utan0.dot(utan);
407        let utan1xn = cx.utan1.dot(utan);
408        let mt = 1.0 - t;
409        let b0 = mt * mt * mt;
410        let b1 = 3.0 * mt * t * mt;
411        let b2 = 3.0 * mt * t * t;
412        let b3 = t * t * t;
413        let a_n = b1 * utan0_n;
414        let b_n = b2 * utan1_n;
415        let c_n = (b0 + b1) * utan0xn + (b2 + b3) * utan1xn - 1.0;
416        TwoPointSample { a_n, b_n, c_n }
417    });
418    let det = s[0].a_n * s[1].b_n - s[1].a_n * s[0].b_n;
419    // Consider both near-zero and NaN determinants to be failures.
420    if det.abs().partial_cmp(&1e-12) != Some(core::cmp::Ordering::Greater) {
421        return None;
422    }
423    let idet = -1.0 / det;
424    let a = idet * (s[0].c_n * s[1].b_n - s[1].c_n * s[0].b_n);
425    let b = idet * (s[0].a_n * s[1].c_n - s[1].a_n * s[0].c_n);
426    Some((a, b))
427}
428
429#[cfg(test)]
430mod tests {
431    use std::f64::consts::PI;
432
433    use crate::{
434        Affine, BezPath, Circle, Diagonal2, Join, ParamCurveMoments, PathEl, Point, Rect, Shape,
435        Size, Vec2, expand_path,
436    };
437
438    #[test]
439    fn expand_circle_basic() {
440        const CENTER: Point = Point::new(100., 100.);
441        const RADIUS: f64 = 10.0;
442        let circle = Circle::new(CENTER, RADIUS);
443        let expected_area = PI * RADIUS.powi(2);
444        let expected_perimeter = 2.0 * PI * RADIUS;
445        assert!((circle.area() - expected_area).abs() < 1e-9);
446        assert!((circle.perimeter(1e-12) - expected_perimeter).abs() < 1e-9);
447        const R_DELTA: f64 = 2.0;
448        const EXPAND: Diagonal2 = Diagonal2::new(R_DELTA, R_DELTA);
449        let expanded = expand_path(circle, EXPAND, Join::Bevel, 4.0, 1e-3);
450        let expected_area_expanded = PI * (RADIUS + R_DELTA).powi(2);
451        let expected_perimeter_expanded = 2.0 * PI * (RADIUS + R_DELTA);
452        assert!((expanded.area() - expected_area_expanded).abs() < 4e-2);
453        assert!((expanded.perimeter(1e-12) - expected_perimeter_expanded).abs() < 3e-3);
454
455        let mirror = Affine::reflect(CENTER, Vec2::new(0.0, 1.0));
456        // We have to convert to a path here as the circle type doesn't support negative area.
457        let circle_mirror = mirror * circle.to_path(1e-3);
458        let expanded_mirror = expand_path(circle_mirror, EXPAND, Join::Bevel, 4.0, 1e-3);
459        assert!((-expanded_mirror.area() - expected_area_expanded).abs() < 4e-2);
460        assert!((expanded_mirror.perimeter(1e-12) - expected_perimeter_expanded).abs() < 3e-3);
461    }
462
463    #[test]
464    fn expand_ellipse_anisotropic() {
465        const CENTER: Point = Point::new(100., 100.);
466        const RADIUS: f64 = 10.0;
467        let circle = Circle::new(CENTER, RADIUS);
468        const STRETCH: f64 = 2.717;
469        let ellipse = Affine::scale_non_uniform(STRETCH, 1.0) * circle;
470        let expected_area = PI * RADIUS.powi(2) * STRETCH;
471        assert!((ellipse.area() - expected_area).abs() < 1e-9);
472        const R_DELTA: f64 = 2.0;
473        const EXPAND: Diagonal2 = Diagonal2::new(R_DELTA * STRETCH, R_DELTA);
474        let expanded = expand_path(ellipse, EXPAND, Join::Bevel, 4.0, 1e-3);
475        let expected_area_expanded = PI * (RADIUS + R_DELTA).powi(2) * STRETCH;
476        assert!((expanded.area() - expected_area_expanded).abs() < 4e-2);
477    }
478
479    #[test]
480    fn expand_rect() {
481        const CENTER: Point = Point::new(100., 100.);
482        const SIZE: Size = Size::new(30., 20.);
483        let rect = Rect::from_center_size(CENTER, SIZE);
484        assert!((rect.area() - SIZE.area()).abs() < 1e-9);
485        const EXPAND: Diagonal2 = Diagonal2::new(10.0, 10.0);
486        let mitered = expand_path(rect, EXPAND, Join::Miter, 4.0, 1e-3);
487        let expected_area_mitered =
488            (SIZE.width + 2.0 * EXPAND.xx) * (SIZE.height + 2.0 * EXPAND.yy);
489        assert!((mitered.area() - expected_area_mitered).abs() < 1e-9);
490
491        let beveled = expand_path(rect, EXPAND, Join::Bevel, 4.0, 1e-3);
492        let expected_area_beveled = expected_area_mitered - 2.0 * EXPAND.xx * EXPAND.yy;
493        assert!((beveled.area() - expected_area_beveled).abs() < 1e-9);
494
495        let rounded = expand_path(rect, EXPAND, Join::Round, 4.0, 0.1);
496        let expected_area_rounded = expected_area_mitered - (4.0 - PI) * EXPAND.xx * EXPAND.yy;
497        assert!((rounded.area() - expected_area_rounded).abs() < 1e-1);
498    }
499
500    #[test]
501    fn expand_rounding_tolerance() {
502        const CENTER: Point = Point::new(100., 100.);
503        const SIZE: Size = Size::new(30., 20.);
504        let rect = Rect::from_center_size(CENTER, SIZE);
505        const EXPAND: Diagonal2 = Diagonal2::new(10.0, 10.0);
506        let expected_area_mitered =
507            (SIZE.width + 2.0 * EXPAND.xx) * (SIZE.height + 2.0 * EXPAND.yy);
508        let expected_area_rounded = expected_area_mitered - (4.0 - PI) * EXPAND.xx * EXPAND.yy;
509        let rounded = expand_path(rect, EXPAND, Join::Round, 4.0, 1e-3);
510        assert!((rounded.area() - expected_area_rounded).abs() < 2e-3);
511        // Validate that a lower tolerance increases accuracy.
512        let rounded_fine = expand_path(rect, EXPAND, Join::Round, 4.0, 1e-6);
513        assert!((rounded_fine.area() - expected_area_rounded).abs() < 3e-5);
514    }
515
516    #[test]
517    fn expand_glyph_shape() {
518        // WARNING: this test is fragile. If there is optimization to simplify inner joins by
519        // computing intersections, the measurements change. A properly written test would
520        // compare the result after path intersection, or alternatively probe for zero/nonzero
521        // winding number for a collection of sample points. But at least it is sensitive to
522        // regressions.
523
524        // Roboto "e" glyph
525        let path = BezPath::from_svg(
526            "M10.359375,-0.359375 Q6.484375,-0.359375 4.0625,2.1875 Q1.640625,4.734375 1.640625,8.984375 \
527             L1.640625,9.578125 Q1.640625,12.40625 2.71875,14.625 Q3.796875,16.859375 5.734375,18.109375 \
528             Q7.6875,19.375 9.953125,19.375 Q13.65625,19.375 15.703125,16.921875 Q17.765625,14.484375 17.765625,9.9375 \
529             L17.765625,8.578125 L4.890625,8.578125 Q4.953125,5.765625 6.53125,4.03125 Q8.109375,2.296875 10.53125,2.296875 \
530             Q12.25,2.296875 13.4375,3 Q14.640625,3.703125 15.546875,4.875 L17.53125,3.328125 Q15.140625,-0.359375 10.359375,-0.359375 Z \
531             M9.953125,16.703125 Q7.984375,16.703125 6.640625,15.265625 Q5.3125,13.828125 5,11.25 L14.515625,11.25 L14.515625,11.5 \
532             Q14.375,13.96875 13.171875,15.328125 Q11.984375,16.703125 9.953125,16.703125 Z",
533        )
534        .unwrap();
535        const EXPAND: Diagonal2 = Diagonal2::new(1.5, 1.0);
536        let expanded = expand_path(&path, EXPAND, Join::Round, 4.0, 0.1);
537        //println!("{}", expanded.to_svg());
538        // The outline has been visually verified, these are measurements taken from known-good.
539        const EXPECTED_AREA: f64 = -291.3217958410297;
540        const EXPECTED_PERIMETER: f64 = 120.89147879578239;
541        const EXPECTED_MOMENT_X: f64 = -2788.568539588466;
542        const EXPECTED_MOMENT_Y: f64 = -2801.3818805722685;
543        const EXPECTED_MOMENT_XX: f64 = -34280.940819978576;
544        const EXPECTED_MOMENT_XY: f64 = -26971.281063345254;
545        const EXPECTED_MOMENT_YY: f64 = -36722.298990246876;
546        assert!((expanded.area() - EXPECTED_AREA).abs() < 1e-3);
547        assert!((expanded.perimeter(1e-9) - EXPECTED_PERIMETER).abs() < 1e-3);
548        let moments = expanded.moments();
549        assert!((moments.moment_x - EXPECTED_MOMENT_X).abs() < 1e-3);
550        assert!((moments.moment_y - EXPECTED_MOMENT_Y).abs() < 1e-3);
551        assert!((moments.moment_xx - EXPECTED_MOMENT_XX).abs() < 1e-3);
552        assert!((moments.moment_xy - EXPECTED_MOMENT_XY).abs() < 1e-3);
553        assert!((moments.moment_yy - EXPECTED_MOMENT_YY).abs() < 1e-3);
554    }
555
556    #[test]
557    fn assert_expand_subpath_closed() {
558        let mut path = BezPath::new();
559        path.move_to((0.0, 0.0));
560        path.line_to((100.0, 0.0));
561        path.line_to((100.0, 100.0));
562        path.line_to((0.0, 100.0));
563
564        let expanded = expand_path(path, Diagonal2::new(10.0, 10.0), Join::Miter, 4.0, 1e-3);
565        assert_eq!(expanded.elements().last(), Some(&PathEl::ClosePath));
566    }
567
568    #[test]
569    fn expand_degenerate_input() {
570        let path = BezPath::from_svg("M0,0 C0,0 0,0 0,0 Z").unwrap();
571        let expanded = expand_path(path, Diagonal2::new(10.0, 10.0), Join::Miter, 4.0, 1e-3);
572        assert!(expanded.is_empty());
573    }
574
575    #[test]
576    fn expand_degenerate_ghost() {
577        let mut path = BezPath::new();
578        path.extend(Rect::new(0.0, 0.0, 10.0, 10.0).path_elements(1e-3));
579        let expected = expand_path(&path, Diagonal2::new(1.0, 1.0), Join::Miter, 4.0, 1e-3);
580
581        path.move_to((20.0, 20.0));
582        path.close_path();
583        let actual = expand_path(&path, Diagonal2::new(1.0, 1.0), Join::Miter, 4.0, 1e-3);
584
585        assert_eq!(actual, expected);
586    }
587
588    #[test]
589    fn expand_open_subpath_uses_implicit_close_for_area_sign() {
590        let mut open = BezPath::new();
591        open.move_to((100.0, 100.0));
592        open.line_to((110.0, 100.0));
593        open.line_to((100.0, 110.0));
594
595        let mut closed = open.clone();
596        closed.close_path();
597
598        let expand = Diagonal2::new(1.0, 1.0);
599        let open_expanded = expand_path(open, expand, Join::Miter, 4.0, 1e-3);
600        let closed_expanded = expand_path(closed, expand, Join::Miter, 4.0, 1e-3);
601
602        assert_eq!(open_expanded, closed_expanded);
603    }
604}