Skip to main content

geometry_overlay/
buffer.rs

1//! OVL7 — `buffer`: grow a geometry outward by a fixed distance.
2//!
3//! Mirrors `boost/geometry/algorithms/buffer.hpp` and the buffer
4//! strategies under `strategies/buffer/`. A buffer offsets every part of
5//! the input outward by `distance`, rounding or mitering the corners,
6//! and unions the offset pieces into an output polygon.
7//!
8//! v1 scope: **positive** distance buffers of a point (→ a circle) and a
9//! convex polygon (→ the polygon grown with rounded corners). The
10//! general non-convex / negative-distance buffer, which needs the full
11//! offset-and-self-union machinery, is deferred — it builds on the same
12//! overlay `union` this module already uses.
13//!
14//! Join / end / point strategies are modelled as small enums
15//! ([`JoinStrategy`], [`PointStrategy`]) mirroring Boost's
16//! `join_round` / `join_miter` and `point_circle` / `point_square`
17//! strategy types.
18
19// Segment counts convert freely between `usize` and `f64` to lay out
20// circle / arc vertices; the values are small angular subdivisions where
21// the sub-mantissa precision loss and the non-negative truncation are
22// intentional and harmless.
23#![allow(
24    clippy::cast_precision_loss,
25    clippy::cast_possible_truncation,
26    clippy::cast_sign_loss,
27    reason = "angular vertex-count arithmetic; values are small and non-negative"
28)]
29// Zero-length guards and closing-vertex identity compare `f64`s exactly
30// on purpose — these are degenerate-case gates, not tolerance checks.
31#![allow(clippy::float_cmp, reason = "exact degenerate-case guards")]
32
33use alloc::vec::Vec;
34
35use geometry_coords::CoordinateScalar;
36use geometry_cs::{CartesianFamily, CoordinateSystem, FromF64};
37use geometry_model::{Polygon, Ring};
38use geometry_tag::SameAs;
39use geometry_trait::{Point, PointMut, Polygon as PolygonTrait, Ring as RingTrait};
40
41/// How to fill the wedge at a convex corner of the offset boundary.
42///
43/// Mirrors `strategy::buffer::join_round` / `join_miter`
44/// (`strategies/buffer/buffer_join_round.hpp` and friends).
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum JoinStrategy {
47    /// Fill the corner with a circular arc of `points_per_circle`
48    /// segments. Boost's `join_round`.
49    Round {
50        /// Segment count of a full circle; the arc uses a proportional
51        /// share.
52        points_per_circle: usize,
53    },
54    /// Extend the two offset edges until they meet at a sharp point.
55    /// Boost's `join_miter`.
56    ///
57    /// The miter length is currently **uncapped**: Boost's
58    /// `miter_limit` (default 5.0 × distance,
59    /// `strategies/buffer/buffer_join_miter.hpp`) is not yet
60    /// implemented, so a near-180° corner produces a proportionally
61    /// long spike. Capping is deferred with the rest of the
62    /// non-convex buffer work.
63    Miter,
64}
65
66/// How to approximate a buffered point.
67///
68/// Mirrors `strategy::buffer::point_circle` / `point_square`
69/// (`strategies/buffer/buffer_point_circle.hpp`).
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum PointStrategy {
72    /// Approximate the buffer disc with a regular polygon of
73    /// `points_per_circle` vertices. Boost's `point_circle`.
74    Circle {
75        /// Vertex count of the approximating polygon.
76        points_per_circle: usize,
77    },
78    /// Approximate the buffer with an axis-aligned square. Boost's
79    /// `point_square`.
80    Square,
81}
82
83/// Buffer a point by `distance`, producing the disc (or square)
84/// approximation.
85///
86/// Mirrors the point arm of `boost::geometry::buffer` with a
87/// `point_circle` / `point_square` strategy
88/// (`strategies/buffer/buffer_point_circle.hpp`).
89///
90/// # Examples
91///
92/// ```
93/// use geometry_cs::Cartesian;
94/// use geometry_model::Point2D;
95/// use geometry_overlay::buffer::{buffer_point, PointStrategy};
96/// use geometry_algorithm::ring_area;
97///
98/// type P = Point2D<f64, Cartesian>;
99/// let disc = buffer_point(&P::new(0.0, 0.0), 1.0, PointStrategy::Circle { points_per_circle: 360 });
100/// // Area of the 360-gon closely approximates π.
101/// assert!((ring_area(&disc).abs() - core::f64::consts::PI).abs() < 1e-3);
102/// ```
103#[must_use]
104pub fn buffer_point<P>(center: &P, distance: f64, strategy: PointStrategy) -> Ring<P>
105where
106    P: PointMut + Default + Copy,
107    P::Scalar: CoordinateScalar + Into<f64> + FromF64,
108    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
109{
110    let cx: f64 = center.get::<0>().into();
111    let cy: f64 = center.get::<1>().into();
112    match strategy {
113        PointStrategy::Circle { points_per_circle } => {
114            circle_ring(cx, cy, distance, points_per_circle.max(3))
115        }
116        PointStrategy::Square => {
117            let d = distance;
118            // Fully-qualified `alloc::vec!`: only the `Vec` *type* is
119            // imported (line 33), and the bare `vec!` macro is not in the
120            // `no_std` prelude — matches the crate idiom in `assemble.rs`
121            // / `traverse/state.rs`.
122            Ring::from_vec(alloc::vec![
123                make_point(cx - d, cy - d),
124                make_point(cx + d, cy - d),
125                make_point(cx + d, cy + d),
126                make_point(cx - d, cy + d),
127                make_point(cx - d, cy - d),
128            ])
129        }
130    }
131}
132
133/// Buffer a **convex** polygon outward by a positive `distance`, rounding
134/// the corners per `join`.
135///
136/// Each vertex of a convex polygon becomes a circular arc of radius
137/// `distance` in the offset boundary; the arcs are joined by the offset
138/// edges. Mirrors the convex case of `boost::geometry::buffer`
139/// (`algorithms/buffer.hpp`) with a `join_round` strategy.
140///
141/// # Panics
142///
143/// Does not panic; a polygon with fewer than 3 exterior vertices returns
144/// an empty ring's polygon.
145///
146/// # Examples
147///
148/// ```
149/// use geometry_cs::Cartesian;
150/// use geometry_model::{polygon, Point2D, Polygon};
151/// use geometry_overlay::buffer::{buffer_convex_polygon, JoinStrategy};
152/// use geometry_algorithm::ring_area;
153/// use geometry_trait::Polygon as _;
154///
155/// type P = Point2D<f64, Cartesian>;
156/// let sq: Polygon<P> = polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
157/// let grown = buffer_convex_polygon(&sq, 1.0, JoinStrategy::Round { points_per_circle: 720 });
158/// // Area = s² + 4·s·d + π·d² = 4 + 8 + π.
159/// let expected = 4.0 + 8.0 + core::f64::consts::PI;
160/// assert!((ring_area(grown.exterior()).abs() - expected).abs() < 5e-2);
161/// ```
162#[must_use]
163pub fn buffer_convex_polygon<G, P>(polygon: &G, distance: f64, join: JoinStrategy) -> Polygon<P>
164where
165    G: PolygonTrait<Point = P>,
166    P: PointMut + Default + Copy,
167    P::Scalar: CoordinateScalar + Into<f64> + FromF64,
168    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
169{
170    let mut verts: Vec<(f64, f64)> = distinct_vertices(polygon.exterior());
171    if verts.len() < 3 {
172        return Polygon::new(Ring::new());
173    }
174
175    // Normalise winding to geometrically counter-clockwise so the
176    // right-hand normal `(dy, -dx)` is genuinely outward and the corner
177    // arc sweeps the short (convex) way. A negative *math* signed area
178    // (CCW-positive convention) means the ring is clockwise and must be
179    // reversed. Reversing a ring negates its signed area without changing
180    // the point set, so the buffered shape is unaffected.
181    if signed_area_ccw_positive(&verts) < 0.0 {
182        verts.reverse();
183    }
184
185    // The outward normal of a CCW edge points to its right. For each
186    // edge, offset both endpoints outward; at each vertex, connect the
187    // incoming and outgoing offset points with an arc (round) or their
188    // intersection (miter). Scratch vertices stay `(f64, f64)` — the
189    // trig-heavy kernel computes in `f64` (mirroring Boost's promoted
190    // floating type) and materialises `P` only at the output boundary.
191    let n = verts.len();
192    let mut boundary: Vec<(f64, f64)> = Vec::new();
193
194    for i in 0..n {
195        let prev = verts[(i + n - 1) % n];
196        let curr = verts[i];
197        let next = verts[(i + 1) % n];
198
199        // Outward normals of the two edges meeting at `curr`.
200        let n_in = outward_normal(curr.0 - prev.0, curr.1 - prev.1);
201        let n_out = outward_normal(next.0 - curr.0, next.1 - curr.1);
202
203        // Offset positions of `curr` along each edge's normal.
204        let p_in = (curr.0 + n_in.0 * distance, curr.1 + n_in.1 * distance);
205        let p_out = (curr.0 + n_out.0 * distance, curr.1 + n_out.1 * distance);
206
207        boundary.push(p_in);
208        match join {
209            JoinStrategy::Round { points_per_circle } => {
210                push_corner_arc(
211                    &mut boundary,
212                    curr,
213                    p_in,
214                    p_out,
215                    distance,
216                    points_per_circle.max(3),
217                );
218            }
219            JoinStrategy::Miter => {
220                // True miter: the intersection of the two offset edge
221                // lines, `curr + s · (2d / |s|²)` with `s = n_in + n_out`
222                // (`|s| = 2·cos(θ/2)`, so the point sits at
223                // `d / cos(θ/2)` along the outward bisector). Mirrors
224                // `strategy::buffer::join_miter`
225                // (`strategies/buffer/buffer_join_miter.hpp`), minus the
226                // miter_limit cap (documented on the enum variant).
227                //
228                // Degenerate guards: a zero-length edge yields a (0,0)
229                // normal and an anti-parallel pair yields `s == (0,0)`;
230                // in both cases there is no finite/meaningful miter
231                // point, so emit none — the `p_in` / `p_out` offset
232                // points already bracket the corner.
233                let n_in_ok = n_in.0 != 0.0 || n_in.1 != 0.0;
234                let n_out_ok = n_out.0 != 0.0 || n_out.1 != 0.0;
235                let sx = n_in.0 + n_out.0;
236                let sy = n_in.1 + n_out.1;
237                let len2 = sx * sx + sy * sy;
238                if n_in_ok && n_out_ok && len2 > 0.0 {
239                    let scale = 2.0 * distance / len2;
240                    boundary.push((curr.0 + sx * scale, curr.1 + sy * scale));
241                }
242            }
243        }
244        boundary.push(p_out);
245    }
246
247    // Close the ring.
248    if let Some(first) = boundary.first().copied() {
249        boundary.push(first);
250    }
251    Polygon::new(Ring::from_vec(
252        boundary
253            .into_iter()
254            .map(|(x, y)| make_point(x, y))
255            .collect(),
256    ))
257}
258
259/// Materialise an output point from the `f64` kernel coordinates.
260fn make_point<P>(x: f64, y: f64) -> P
261where
262    P: PointMut + Default,
263    P::Scalar: FromF64,
264{
265    let mut p = P::default();
266    p.set::<0>(P::Scalar::from_f64(x));
267    p.set::<1>(P::Scalar::from_f64(y));
268    p
269}
270
271/// A regular-polygon approximation of a circle, CCW, closed.
272fn circle_ring<P>(cx: f64, cy: f64, r: f64, segments: usize) -> Ring<P>
273where
274    P: PointMut + Default + Copy,
275    P::Scalar: FromF64,
276{
277    let mut pts = Vec::with_capacity(segments + 1);
278    let step = core::f64::consts::TAU / segments as f64;
279    for k in 0..segments {
280        let a = step * k as f64;
281        pts.push(make_point(cx + r * a.cos(), cy + r * a.sin()));
282    }
283    pts.push(pts[0]);
284    Ring::from_vec(pts)
285}
286
287/// Distinct consecutive vertices of a ring as `f64` pairs (drops the
288/// closing repeat).
289fn distinct_vertices<R>(ring: &R) -> Vec<(f64, f64)>
290where
291    R: RingTrait,
292    <R::Point as Point>::Scalar: Into<f64>,
293{
294    let mut pts: Vec<(f64, f64)> = ring
295        .points()
296        .map(|p| (p.get::<0>().into(), p.get::<1>().into()))
297        .collect();
298    if pts.len() >= 2 {
299        let first = pts[0];
300        let last = pts[pts.len() - 1];
301        if first == last {
302            pts.pop();
303        }
304    }
305    pts
306}
307
308/// The standard math signed area of the vertex ring (counter-clockwise
309/// positive), via the shoelace sum over the closed loop. Used only to
310/// detect winding for normalisation.
311fn signed_area_ccw_positive(verts: &[(f64, f64)]) -> f64 {
312    let n = verts.len();
313    let mut acc = 0.0;
314    for i in 0..n {
315        let a = verts[i];
316        let b = verts[(i + 1) % n];
317        acc += a.0 * b.1 - b.0 * a.1;
318    }
319    acc * 0.5
320}
321
322/// The outward unit normal of a directed CCW edge with delta
323/// `(dx, dy)` (pointing to the edge's right).
324fn outward_normal(dx: f64, dy: f64) -> (f64, f64) {
325    let len = (dx * dx + dy * dy).sqrt();
326    if len == 0.0 {
327        return (0.0, 0.0);
328    }
329    // Right-hand normal of (dx, dy) is (dy, -dx).
330    (dy / len, -dx / len)
331}
332
333/// Push the arc that rounds a convex corner, from offset point `from`
334/// to `to`, centred on the original vertex `center` at radius
335/// `distance`.
336fn push_corner_arc(
337    out: &mut Vec<(f64, f64)>,
338    center: (f64, f64),
339    from: (f64, f64),
340    to: (f64, f64),
341    distance: f64,
342    points_per_circle: usize,
343) {
344    let (cx, cy) = center;
345    let a0 = (from.1 - cy).atan2(from.0 - cx);
346    let mut a1 = (to.1 - cy).atan2(to.0 - cx);
347    // Sweep the short way, counter-clockwise (positive) for a convex CCW
348    // corner.
349    while a1 < a0 {
350        a1 += core::f64::consts::TAU;
351    }
352    let sweep = a1 - a0;
353    let steps = ((sweep / core::f64::consts::TAU) * points_per_circle as f64).ceil() as usize;
354    let steps = steps.max(1);
355    for k in 1..steps {
356        let a = a0 + sweep * (k as f64 / steps as f64);
357        out.push((cx + distance * a.cos(), cy + distance * a.sin()));
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    //! OVL7 done-when: buffered areas match the closed-form values.
364    //! Mirrors `test/algorithms/buffer/`.
365
366    use super::{JoinStrategy, PointStrategy, buffer_convex_polygon, buffer_point};
367    use geometry_algorithm::ring_area;
368    use geometry_cs::Cartesian;
369    use geometry_model::{Point2D, Polygon, polygon};
370    use geometry_trait::Polygon as _;
371
372    type P = Point2D<f64, Cartesian>;
373
374    fn close(a: f64, b: f64, tol: f64) {
375        assert!((a - b).abs() < tol, "expected {b}, got {a}");
376    }
377
378    #[test]
379    fn point_circle_area_approximates_pi_r_squared() {
380        let disc = buffer_point(
381            &P::new(0.0, 0.0),
382            2.0,
383            PointStrategy::Circle {
384                points_per_circle: 720,
385            },
386        );
387        // π·r² = π·4.
388        close(ring_area(&disc).abs(), core::f64::consts::PI * 4.0, 1e-2);
389    }
390
391    #[test]
392    fn point_square_area() {
393        let sq = buffer_point(&P::new(0.0, 0.0), 3.0, PointStrategy::Square);
394        // A square of half-side 3 → side 6 → area 36.
395        close(ring_area(&sq).abs(), 36.0, 1e-9);
396    }
397
398    #[test]
399    fn convex_square_round_buffer_area() {
400        let sq: Polygon<P> = polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
401        let grown = buffer_convex_polygon(
402            &sq,
403            1.0,
404            JoinStrategy::Round {
405                points_per_circle: 720,
406            },
407        );
408        // s² + 4·s·d + π·d² = 4 + 8 + π.
409        let expected = 4.0 + 8.0 + core::f64::consts::PI;
410        close(ring_area(grown.exterior()).abs(), expected, 1e-2);
411    }
412
413    #[test]
414    fn convex_triangle_round_buffer_grows() {
415        let tri: Polygon<P> = polygon![[(0.0, 0.0), (4.0, 0.0), (0.0, 3.0), (0.0, 0.0)]];
416        let base = ring_area(tri.exterior()).abs(); // 6
417        let grown = buffer_convex_polygon(
418            &tri,
419            0.5,
420            JoinStrategy::Round {
421                points_per_circle: 360,
422            },
423        );
424        // The buffered area must exceed the original.
425        assert!(ring_area(grown.exterior()).abs() > base);
426    }
427
428    #[test]
429    fn buffer_is_winding_independent() {
430        // Regression: the same square listed clockwise and counter-
431        // clockwise must buffer to the same grown area. The winding
432        // normalisation makes the outward offset direction correct for
433        // both.
434        let ccw: Polygon<P> =
435            polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
436        let cw: Polygon<P> = polygon![[(0.0, 0.0), (0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)]];
437        let j = JoinStrategy::Round {
438            points_per_circle: 720,
439        };
440        let expected = 4.0 + 8.0 + core::f64::consts::PI;
441        let grown_from_counterclockwise =
442            ring_area(buffer_convex_polygon(&ccw, 1.0, j).exterior()).abs();
443        let grown_from_clockwise = ring_area(buffer_convex_polygon(&cw, 1.0, j).exterior()).abs();
444        close(grown_from_counterclockwise, expected, 5e-2);
445        close(grown_from_clockwise, expected, 5e-2);
446    }
447
448    #[test]
449    fn miter_square_area_is_16() {
450        // Regression: the old Miter arm placed the corner point at
451        // distance d along the bisector (ON the round arc), yielding
452        // 14.83 — smaller than even the round buffer. A true miter
453        // corner is the offset-edge intersection at √2·d, so the
454        // buffered 2×2 square is s² + 4·s·d + 4·d² = 16 exactly.
455        let sq: Polygon<P> = polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
456        let grown = buffer_convex_polygon(&sq, 1.0, JoinStrategy::Miter);
457        close(ring_area(grown.exterior()).abs(), 16.0, 1e-9);
458    }
459
460    #[test]
461    fn miter_contains_near_corner_probe() {
462        // A point at distance 0.99 < d from the input corner, in the
463        // 22.5° direction, was EXCLUDED by the old chord-cut corner.
464        use geometry_algorithm::within;
465        let sq: Polygon<P> = polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
466        let grown = buffer_convex_polygon(&sq, 1.0, JoinStrategy::Miter);
467        let ang = 22.5_f64.to_radians();
468        let probe = P::new(2.0 + 0.99 * ang.cos(), 2.0 + 0.99 * ang.sin());
469        assert!(
470            within(&probe, &grown),
471            "buffer must contain points within d"
472        );
473    }
474
475    #[test]
476    fn miter_is_superset_of_round_by_area() {
477        // A miter fills the wedge beyond the round arc, so its area
478        // can never be below the round join's.
479        let j_round = JoinStrategy::Round {
480            points_per_circle: 720,
481        };
482        let square: Polygon<P> =
483            polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
484        let triangle: Polygon<P> = polygon![[(0.0, 0.0), (4.0, 0.0), (0.0, 3.0), (0.0, 0.0)]];
485        for pg in [square, triangle] {
486            let m =
487                ring_area(buffer_convex_polygon(&pg, 1.0, JoinStrategy::Miter).exterior()).abs();
488            let r = ring_area(buffer_convex_polygon(&pg, 1.0, j_round).exterior()).abs();
489            assert!(m >= r - 1e-9, "miter {m} must not be below round {r}");
490        }
491    }
492
493    #[test]
494    fn non_model_polygon_buffers_like_the_model_polygon() {
495        // The generic signature accepts any `Polygon` trait impl — a
496        // hand-rolled type must buffer to the same area as the same
497        // shape held in a model polygon.
498        use geometry_model::Ring;
499        use geometry_tag::PolygonTag;
500        use geometry_trait::{Geometry, Polygon as PolygonTrait};
501
502        struct Parcel {
503            outer: Ring<P>,
504        }
505        impl Geometry for Parcel {
506            type Kind = PolygonTag;
507            type Point = P;
508        }
509        impl PolygonTrait for Parcel {
510            type Ring = Ring<P>;
511            fn exterior(&self) -> &Ring<P> {
512                &self.outer
513            }
514            fn interiors(&self) -> impl ExactSizeIterator<Item = &Ring<P>> {
515                core::iter::empty()
516            }
517        }
518
519        let pts = vec![
520            P::new(0.0, 0.0),
521            P::new(2.0, 0.0),
522            P::new(2.0, 2.0),
523            P::new(0.0, 2.0),
524            P::new(0.0, 0.0),
525        ];
526        let parcel = Parcel {
527            outer: Ring::from_vec(pts.clone()),
528        };
529        let model: Polygon<P> = Polygon::new(Ring::from_vec(pts));
530        let j = JoinStrategy::Round {
531            points_per_circle: 360,
532        };
533        let a = ring_area(buffer_convex_polygon(&parcel, 1.0, j).exterior()).abs();
534        let b = ring_area(buffer_convex_polygon(&model, 1.0, j).exterior()).abs();
535        close(a, b, 1e-12);
536    }
537
538    #[test]
539    fn miter_is_winding_independent() {
540        // Same square listed CW and CCW buffers to the same miter area.
541        let ccw: Polygon<P> =
542            polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
543        let cw: Polygon<P> = polygon![[(0.0, 0.0), (0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)]];
544        close(
545            ring_area(buffer_convex_polygon(&ccw, 1.0, JoinStrategy::Miter).exterior()).abs(),
546            16.0,
547            1e-9,
548        );
549        close(
550            ring_area(buffer_convex_polygon(&cw, 1.0, JoinStrategy::Miter).exterior()).abs(),
551            16.0,
552            1e-9,
553        );
554    }
555}