Skip to main content

path_offset/path/
mod.rs

1//! Defines the `Path` struct and related utilities for path manipulation.
2//!
3//! This module provides the core `Path` struct, which represents a geometric path,
4//! and includes functionality for parsing, manipulating, and iterating over paths.
5
6use std::{fmt::Display, str::FromStr};
7
8use lyon::path::Event;
9
10use crate::error::PathError;
11
12pub mod conversions;
13pub mod point;
14pub mod subpath;
15
16/// Represents a geometric path, composed of one or more subpaths.
17///
18/// A `Path` can be created from an SVG path string and can be iterated over
19/// to process its individual subpaths. It also provides utilities for
20/// analyzing path properties, such as finding the outermost contour.
21#[derive(Debug, Clone)]
22pub struct Path {
23    inner: lyon::path::Path,
24}
25
26impl Path {
27    /// Returns an iterator over the subpaths of this path.
28    ///
29    /// Each item in the iterator is a `Path` representing a single subpath.
30    pub fn iter(&self) -> impl Iterator<Item = Path> + '_ {
31        self.into_iter()
32    }
33
34    /// Checks if the path is closed.
35    ///
36    /// A path is considered closed if it ends with a `Close` event.
37    pub fn is_closed(&self) -> bool {
38        self.inner
39            .iter()
40            .any(|e| matches!(e, Event::End { close: true, .. }))
41    }
42
43    /// Find and return the subpath that represents the outermost shell.
44    ///
45    /// This method first attempts to use a fast "largest area" heuristic.
46    /// If that fails to produce a result, it falls back to a more accurate but slower
47    /// "geometric containment" algorithm.
48    ///
49    /// # Returns
50    ///
51    /// An `Option<Path>` containing the outermost shell if found, otherwise `None`.
52    pub fn find_outer_shell(&self) -> Option<Path> {
53        let subpaths: Vec<Path> = self.iter().collect();
54
55        match subpaths.len() {
56            // Case 1: No subpaths
57            0 => None,
58
59            // Case 2: Only one subpath, which is the shell by definition.
60            // We use .into_iter().next() to consume the Vec and take the single element
61            // without needing to clone it.
62            1 => subpaths.into_iter().next(),
63
64            // Case 3: Multiple subpaths, execute the "smart" finding logic.
65            _ => {
66                // First, try the fast area heuristic.
67                find_shell_by_area(&subpaths)
68                    // If the area method returns nothing, fall back to the precise geometric containment algorithm.
69                    .or_else(|| find_shell_by_containment(&subpaths))
70            }
71        }
72    }
73
74    /// Checks if this path's bounding box intersects with another path's bounding box.
75    fn intersect_with(&self, other: &Path) -> bool {
76        let bbox_a = lyon::algorithms::aabb::bounding_box(self.inner.iter());
77        let bbox_b = lyon::algorithms::aabb::bounding_box(other.inner.iter());
78        bbox_a.intersects(&bbox_b)
79    }
80
81    /// Checks if this path is geometrically contained within another path.
82    fn contained_by(&self, other_path: &Path) -> bool {
83        // A path cannot contain itself.
84        !std::ptr::eq(self, other_path)
85            // Both paths must be closed to have a well-defined interior.
86            && self.is_closed()
87            && other_path.is_closed()
88            // Check if the first point of this path is inside the other path.
89            && self.inner.first_endpoint().map_or(false, |(pt, _)| {
90                lyon::algorithms::hit_test::hit_test_path(
91                    &pt,
92                    &other_path.inner,
93                    lyon::path::FillRule::EvenOdd,
94                    0.1,
95                )
96            })
97    }
98}
99
100/// Parses a `Path` from an SVG path data string.
101///
102/// # Errors
103///
104/// Returns a `PathError` if the SVG path data is invalid.
105impl FromStr for Path {
106    type Err = PathError;
107
108    fn from_str(s: &str) -> Result<Self, Self::Err> {
109        let mut parser = lyon::extra::parser::PathParser::new();
110        let mut builder = lyon::path::Path::builder();
111        let mut src = lyon::extra::parser::Source::new(s.chars());
112
113        parser.parse(
114            &lyon::extra::parser::ParserOptions::DEFAULT,
115            &mut src,
116            &mut builder,
117        )?;
118
119        let path = builder.build();
120        Ok(Path::from(path))
121    }
122}
123
124/// Formats the `Path` as an SVG path data string.
125impl Display for Path {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        let path_slice = self.inner.as_slice();
128
129        for event in path_slice.iter_with_attributes() {
130            match event {
131                Event::Begin { at: (at, _) } => {
132                    write!(f, "M{},{}", at.x, at.y)?;
133                }
134                Event::Line { to: (to, _), .. } => {
135                    write!(f, "L{},{}", to.x, to.y)?;
136                }
137                Event::Quadratic {
138                    ctrl, to: (to, _), ..
139                } => {
140                    write!(f, "Q{},{} {},{}", ctrl.x, ctrl.y, to.x, to.y)?;
141                }
142                Event::Cubic {
143                    ctrl1,
144                    ctrl2,
145                    to: (to, _),
146                    ..
147                } => {
148                    write!(
149                        f,
150                        "C{},{} {},{} {},{}",
151                        ctrl1.x, ctrl1.y, ctrl2.x, ctrl2.y, to.x, to.y
152                    )?;
153                }
154                Event::End { close, .. } => {
155                    if close {
156                        write!(f, "Z")?;
157                    }
158                }
159            }
160        }
161
162        Ok(())
163    }
164}
165
166/// Strategy 1: Find the outermost shell by calculating signed area.
167/// This is a fast heuristic.
168fn find_shell_by_area(paths: &[Path]) -> Option<Path> {
169    paths
170        .iter()
171        // Only consider closed paths, as only they can define an inside and outside.
172        .filter(|p| p.is_closed())
173        .max_by(|a, b| {
174            let area_a = lyon::algorithms::area::approximate_signed_area(0.01, a.inner.iter());
175            let area_b = lyon::algorithms::area::approximate_signed_area(0.01, b.inner.iter());
176            // total_cmp can handle special f32 cases like NaN and infinity.
177            area_a.total_cmp(&area_b)
178        })
179        .cloned()
180}
181
182/// Strategy 2: Find the outermost shell by checking for geometric containment.
183/// This is a precise but computationally more expensive algorithm.
184fn find_shell_by_containment(paths: &[Path]) -> Option<Path> {
185    paths
186        .iter()
187        .find(|this_path| {
188            // Find a path that is not contained by any other path.
189            !paths.iter().any(|other_path| {
190                // Use our previously defined helper methods.
191                this_path.intersect_with(other_path) && this_path.contained_by(other_path)
192            })
193        })
194        .cloned()
195}