Skip to main content

xpict_core/geom/
mod.rs

1//! Geometry for halos and ink (Shapely stand-in).
2//!
3//! Capsule/disk helpers always available. Full multipolygon buffer / boolean
4//! ops live behind the `geom` feature (`i_overlay`) — enable for Python so
5//! Shapely can leave the runtime dependency set.
6
7use std::f64::consts::{PI, TAU};
8
9/// Closed polygon as SVG path `d` (absolute M/L/Z).
10pub 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
22/// Approximate a circle as a closed polygon (Shapely `quad_segs` style).
23pub 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/// Semicircle on a round cap at `tip`; `u` points along the segment into the cap.
37#[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
58/// Filled capsule: segment `(x1,y1)–(x2,y2)` thickened by `radius`, round caps.
59pub 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
93/// White knockout path for a stroked capsule (ink radius + grow distance).
94pub 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
113/// Disk ink halo (point buffer then grow → single circle at `r + grow`).
114pub 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        // Center stays open…
164        assert!(!halo.contains(10.0, 10.0));
165        // …but the offset inner rim is covered (just inside the original hole).
166        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        // Mid-edge points are collinear — from_ring runs lib simplify.
174        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)); // inner rim
205        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.)]; // CW
222        let inner: Vec<(f64, f64)> = vec![(2., 2.), (8., 2.), (8., 8.), (2., 8.)]; // CCW
223        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        // Degenerate segment → disk at the tip.
243        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    /// Parity with Python ``test_shape_evenodd_and_halo`` (square annulus).
254    #[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    /// Parity with Python ``test_capsule_halo_path_closed`` / OFFSET_PX constant.
271    #[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        // Stroke wider than diameter → filled disk (no hole).
319        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        // horizontal_span_at: disk mid-height has positive width; miss → 0.
332        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}