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}