1use std::f64::consts::{PI, TAU};
8
9pub fn polygon_to_svg_d(ring: &[(f64, f64)]) -> String {
11 if ring.len() < 3 {
12 return String::new();
13 }
14 let mut out = format!("M {:.2} {:.2}", ring[0].0, ring[0].1);
15 for &(x, y) in &ring[1..] {
16 out.push_str(&format!(" L {:.2} {:.2}", x, y));
17 }
18 out.push_str(" Z");
19 out
20}
21
22pub fn circle_polygon(cx: f64, cy: f64, radius: f64, quad_segs: u32) -> Vec<(f64, f64)> {
24 if radius <= 0.0 {
25 return Vec::new();
26 }
27 let segs = quad_segs.max(3) as usize;
28 (0..segs)
29 .map(|i| {
30 let t = TAU * i as f64 / segs as f64;
31 (cx + radius * t.cos(), cy + radius * t.sin())
32 })
33 .collect()
34}
35
36#[allow(clippy::too_many_arguments)]
38fn cap_arc(
39 tip_x: f64,
40 tip_y: f64,
41 nx: f64,
42 ny: f64,
43 ux: f64,
44 uy: f64,
45 radius: f64,
46 steps: usize,
47) -> Vec<(f64, f64)> {
48 let mut pts = Vec::with_capacity(steps + 1);
49 for i in 0..=steps {
50 let t = PI * i as f64 / steps as f64;
51 let px = tip_x + radius * (nx * t.cos() + ux * t.sin());
52 let py = tip_y + radius * (ny * t.cos() + uy * t.sin());
53 pts.push((px, py));
54 }
55 pts
56}
57
58pub fn capsule_polygon(
60 x1: f64,
61 y1: f64,
62 x2: f64,
63 y2: f64,
64 radius: f64,
65 quad_segs: u32,
66) -> Vec<(f64, f64)> {
67 if radius <= 0.0 {
68 return Vec::new();
69 }
70 let dx = x2 - x1;
71 let dy = y2 - y1;
72 let len = (dx * dx + dy * dy).sqrt();
73 if len < 1e-12 {
74 return circle_polygon(x1, y1, radius, quad_segs);
75 }
76 let ux = dx / len;
77 let uy = dy / len;
78 let nx = -uy;
79 let ny = ux;
80 let steps = quad_segs.max(3) as usize;
81 let half = steps / 2;
82
83 let mut ring = Vec::new();
84 ring.push((x1 + nx * radius, y1 + ny * radius));
85 ring.push((x2 + nx * radius, y2 + ny * radius));
86 ring.extend(cap_arc(x2, y2, nx, ny, ux, uy, radius, half));
87 ring.push((x2 - nx * radius, y2 - ny * radius));
88 ring.push((x1 - nx * radius, y1 - ny * radius));
89 ring.extend(cap_arc(x1, y1, -nx, -ny, -ux, -uy, radius, half));
90 ring
91}
92
93pub fn capsule_halo_path_d(
95 x1: f64,
96 y1: f64,
97 x2: f64,
98 y2: f64,
99 ink_radius: f64,
100 grow: f64,
101) -> Option<String> {
102 if grow <= 0.0 || ink_radius < 0.0 {
103 return None;
104 }
105 let total = ink_radius + grow;
106 let ring = capsule_polygon(x1, y1, x2, y2, total, 8);
107 if ring.len() < 3 {
108 return None;
109 }
110 Some(polygon_to_svg_d(&ring))
111}
112
113pub fn disk_halo_path_d(cx: f64, cy: f64, ink_radius: f64, grow: f64) -> Option<String> {
115 if grow <= 0.0 || ink_radius < 0.0 {
116 return None;
117 }
118 let ring = circle_polygon(cx, cy, ink_radius + grow, 12);
119 if ring.is_empty() {
120 return None;
121 }
122 Some(polygon_to_svg_d(&ring))
123}
124
125#[cfg(feature = "geom")]
126mod ops;
127
128#[cfg(feature = "geom")]
129pub use ops::Shape;
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn capsule_path_is_closed() {
137 let d = capsule_halo_path_d(0.0, 0.0, 20.0, 0.0, 1.0, 2.0).unwrap();
138 assert!(d.starts_with('M'));
139 assert!(d.ends_with('Z'));
140 }
141
142 #[cfg(feature = "geom")]
143 #[test]
144 fn evenodd_ring_has_hole_and_halo_keeps_center_empty() {
145 let outer: Vec<(f64, f64)> = (0..16)
146 .map(|i| {
147 let t = TAU * i as f64 / 16.0;
148 (10.0 + 8.0 * t.cos(), 10.0 + 8.0 * t.sin())
149 })
150 .collect();
151 let inner: Vec<(f64, f64)> = (0..16)
152 .map(|i| {
153 let t = TAU * i as f64 / 16.0;
154 (10.0 + 4.0 * t.cos(), 10.0 + 4.0 * t.sin())
155 })
156 .collect();
157 let ink = Shape::from_contours_evenodd(&[outer, inner]);
158 assert!(!ink.is_empty());
159 assert!(ink.has_holes());
160 assert!(!ink.contains(10.0, 10.0));
161 let halo = ink.halo(1.0);
162 assert!(!halo.is_empty());
163 assert!(!halo.contains(10.0, 10.0));
165 assert!(halo.contains(10.0 + 3.2, 10.0));
167 assert!(halo.area() > 0.0);
168 }
169
170 #[cfg(feature = "geom")]
171 #[test]
172 fn lib_simplify_drops_collinear_then_halo_keeps_o_hole() {
173 let ring = vec![
175 (0.0, 0.0),
176 (5.0, 0.0),
177 (10.0, 0.0),
178 (10.0, 5.0),
179 (10.0, 10.0),
180 (0.0, 10.0),
181 (0.0, 5.0),
182 ];
183 let ink = Shape::from_ring(&ring);
184 assert!(ink.point_count() < ring.len());
185
186 let outer: Vec<(f64, f64)> = (0..24)
187 .map(|i| {
188 let t = TAU * i as f64 / 24.0;
189 (20.0 + 10.0 * t.cos(), 20.0 + 10.0 * t.sin())
190 })
191 .collect();
192 let inner: Vec<(f64, f64)> = (0..24)
193 .map(|i| {
194 let t = TAU * i as f64 / 24.0;
195 (20.0 + 5.0 * t.cos(), 20.0 + 5.0 * t.sin())
196 })
197 .collect();
198 let ring = Shape::from_contours_evenodd(&[outer, inner]);
199 assert!(ring.has_holes());
200 assert!(ring.simplify().has_holes());
201 let halo = ring.halo(1.5);
202 assert!(!halo.is_empty());
203 assert!(!halo.contains(20.0, 20.0));
204 assert!(halo.contains(20.0 + 4.0, 20.0)); assert!(halo.area() > ring.area());
206 }
207
208 #[cfg(feature = "geom")]
209 #[test]
210 fn capsule_union_and_svg() {
211 let a = Shape::capsule(0.0, 0.0, 20.0, 0.0, 1.0);
212 let b = Shape::disk(10.0, 0.0, 2.0, 12);
213 let u = a.union(&b);
214 let d = u.to_svg_d();
215 assert!(d.contains('M') && d.contains('Z'));
216 }
217
218 #[cfg(feature = "geom")]
219 #[test]
220 fn evenodd_orients_exterior_ccw() {
221 let outer: Vec<(f64, f64)> = vec![(0., 0.), (0., 10.), (10., 10.), (10., 0.)]; let inner: Vec<(f64, f64)> = vec![(2., 2.), (8., 2.), (8., 8.), (2., 8.)]; let s = Shape::from_contours_evenodd(&[outer, inner]);
224 let polys = s.polygons_rings();
225 assert_eq!(polys.len(), 1);
226 let ext = &polys[0].0;
227 let mut a = 0.0;
228 for i in 0..ext.len() {
229 let j = (i + 1) % ext.len();
230 a += ext[i].0 * ext[j].1 - ext[j].0 * ext[i].1;
231 }
232 assert!(a > 0.0, "exterior should be CCW, got {a}");
233 assert!(s.buffer(1.0).area() > s.area());
234 }
235
236 #[test]
237 fn polygon_and_halo_guards() {
238 assert!(polygon_to_svg_d(&[(0.0, 0.0), (1.0, 0.0)]).is_empty());
239 assert!(circle_polygon(0.0, 0.0, 0.0, 8).is_empty());
240 assert!(circle_polygon(0.0, 0.0, -1.0, 8).is_empty());
241 assert!(capsule_polygon(0.0, 0.0, 10.0, 0.0, 0.0, 8).is_empty());
242 let disk = capsule_polygon(1.0, 2.0, 1.0, 2.0, 3.0, 8);
244 assert!(disk.len() >= 3);
245 assert!(capsule_halo_path_d(0.0, 0.0, 10.0, 0.0, 1.0, 0.0).is_none());
246 assert!(capsule_halo_path_d(0.0, 0.0, 10.0, 0.0, -1.0, 1.0).is_none());
247 assert!(disk_halo_path_d(0.0, 0.0, 1.0, 0.0).is_none());
248 assert!(disk_halo_path_d(0.0, 0.0, -0.1, 1.0).is_none());
249 let d = disk_halo_path_d(0.0, 0.0, 1.0, 2.0).unwrap();
250 assert!(d.ends_with('Z'));
251 }
252
253 #[cfg(feature = "geom")]
255 #[test]
256 fn shape_evenodd_square_annulus_and_halo() {
257 let outer = vec![(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)];
258 let inner = vec![(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0)];
259 let ink = Shape::from_contours_evenodd(&[outer, inner]);
260 assert!(!ink.is_empty());
261 assert!(ink.has_holes());
262 assert!(!ink.contains(10.0, 10.0));
263 let halo = ink.halo(1.0);
264 assert!(!halo.is_empty());
265 assert!(!halo.contains(10.0, 10.0));
266 assert!(halo.area() > ink.area());
267 assert!(ink.to_svg_d().contains('M'));
268 }
269
270 #[test]
272 fn capsule_halo_closed_and_offset_px() {
273 use crate::metrics::{LABEL_GAP_PX, OFFSET_PX};
274 let d = capsule_halo_path_d(0.0, 0.0, 20.0, 0.0, 0.56, LABEL_GAP_PX).unwrap();
275 assert!(d.ends_with('Z'));
276 assert!((OFFSET_PX - 3.0).abs() < 1e-9);
277 }
278
279 #[cfg(feature = "geom")]
280 #[test]
281 fn shape_ops_cover_buffers_booleans_and_empty() {
282 assert!(Shape::empty().is_empty());
283 assert!(Shape::from_ring(&[(0.0, 0.0), (1.0, 0.0)]).is_empty());
284 assert!(Shape::from_contours_evenodd(&[vec![(0.0, 0.0), (1.0, 0.0)]]).is_empty());
285 assert!(Shape::from_contours_evenodd(&[]).is_empty());
286
287 let a = Shape::disk(0.0, 0.0, 5.0, 12);
288 let b = Shape::disk(8.0, 0.0, 5.0, 12);
289 assert!(!a.union(&Shape::empty()).is_empty());
290 assert!(!Shape::empty().union(&a).is_empty());
291 assert!(Shape::empty().difference(&a).is_empty());
292 assert!(!a.difference(&Shape::empty()).is_empty());
293 let diff = a.difference(&b);
294 assert!(!diff.is_empty());
295 let x = a.xor(&b);
296 assert!(!x.is_empty());
297 assert_eq!(a.buffer(0.0).point_count(), a.point_count());
298 assert!(Shape::empty().simplify().is_empty());
299 assert!(Shape::empty().halo(1.0).is_empty());
300 assert!(a.halo(0.0).is_empty());
301
302 assert!(Shape::polyline_buffer(&[], 1.0).is_empty());
303 assert!(Shape::polyline_buffer(&[(0.0, 0.0)], 1.0).is_empty());
304 assert!(Shape::polyline_buffer(&[(0.0, 0.0), (10.0, 0.0)], 0.0).is_empty());
305 let cap = Shape::polyline_buffer(&[(0.0, 0.0), (10.0, 0.0)], 1.0);
306 assert!(!cap.is_empty());
307 let poly = Shape::polyline_buffer(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], 1.5);
308 assert!(!poly.is_empty());
309
310 let multi = Shape::multipoint_buffer(&[(0.0, 0.0), (20.0, 0.0)], 2.0);
311 assert!(!multi.is_empty());
312
313 assert!(Shape::annular(0.0, 0.0, 0.0, 1.0).is_empty());
314 assert!(Shape::annular(0.0, 0.0, 5.0, 0.0).is_empty());
315 let ring = Shape::annular(0.0, 0.0, 10.0, 2.0);
316 assert!(!ring.is_empty());
317 assert!(ring.has_holes() || ring.area() > 0.0);
318 let fat = Shape::annular(0.0, 0.0, 1.0, 4.0);
320 assert!(!fat.is_empty());
321
322 let moved = a.translate(3.0, -2.0);
323 let c = moved.centroid().unwrap();
324 assert!((c.0 - 3.0).abs() < 0.5);
325 assert!((c.1 + 2.0).abs() < 0.5);
326 let flipped = a.scale(-1.0, 1.0, 0.0, 0.0);
327 assert!(!flipped.is_empty());
328 assert!(a.contains(0.0, 0.0));
329 assert!(!a.contains(100.0, 100.0));
330
331 let span = a.horizontal_span_at(0.0);
333 assert!(span > 5.0);
334 assert_eq!(a.horizontal_span_at(1000.0), 0.0);
335 }
336}