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_cs::{CartesianFamily, CoordinateSystem};
36use geometry_model::{Point2D, Polygon, Ring};
37use geometry_trait::{Point, Polygon as PolygonTrait, Ring as RingTrait};
38
39/// How to fill the wedge at a convex corner of the offset boundary.
40///
41/// Mirrors `strategy::buffer::join_round` / `join_miter`
42/// (`strategies/buffer/buffer_join_round.hpp` and friends).
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum JoinStrategy {
45    /// Fill the corner with a circular arc of `points_per_circle`
46    /// segments. Boost's `join_round`.
47    Round {
48        /// Segment count of a full circle; the arc uses a proportional
49        /// share.
50        points_per_circle: usize,
51    },
52    /// Extend the two offset edges until they meet at a sharp point.
53    /// Boost's `join_miter`.
54    ///
55    /// The miter length is currently **uncapped**: Boost's
56    /// `miter_limit` (default 5.0 × distance,
57    /// `strategies/buffer/buffer_join_miter.hpp`) is not yet
58    /// implemented, so a near-180° corner produces a proportionally
59    /// long spike. Capping is deferred with the rest of the
60    /// non-convex buffer work.
61    Miter,
62}
63
64/// How to approximate a buffered point.
65///
66/// Mirrors `strategy::buffer::point_circle` / `point_square`
67/// (`strategies/buffer/buffer_point_circle.hpp`).
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum PointStrategy {
70    /// Approximate the buffer disc with a regular polygon of
71    /// `points_per_circle` vertices. Boost's `point_circle`.
72    Circle {
73        /// Vertex count of the approximating polygon.
74        points_per_circle: usize,
75    },
76    /// Approximate the buffer with an axis-aligned square. Boost's
77    /// `point_square`.
78    Square,
79}
80
81/// Buffer a point by `distance`, producing the disc (or square)
82/// approximation.
83///
84/// Mirrors the point arm of `boost::geometry::buffer` with a
85/// `point_circle` / `point_square` strategy
86/// (`strategies/buffer/buffer_point_circle.hpp`).
87///
88/// # Examples
89///
90/// ```
91/// use geometry_cs::Cartesian;
92/// use geometry_model::Point2D;
93/// use geometry_overlay::buffer::{buffer_point, PointStrategy};
94/// use geometry_algorithm::ring_area;
95///
96/// type P = Point2D<f64, Cartesian>;
97/// let disc = buffer_point(&P::new(0.0, 0.0), 1.0, PointStrategy::Circle { points_per_circle: 360 });
98/// // Area of the 360-gon closely approximates π.
99/// assert!((ring_area(&disc).abs() - core::f64::consts::PI).abs() < 1e-3);
100/// ```
101#[must_use]
102pub fn buffer_point<Cs>(
103    center: &Point2D<f64, Cs>,
104    distance: f64,
105    strategy: PointStrategy,
106) -> Ring<Point2D<f64, Cs>>
107where
108    Cs: CoordinateSystem<Family = CartesianFamily> + Copy,
109{
110    let cx = center.get::<0>();
111    let cy = center.get::<1>();
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                Point2D::new(cx - d, cy - d),
124                Point2D::new(cx + d, cy - d),
125                Point2D::new(cx + d, cy + d),
126                Point2D::new(cx - d, cy + d),
127                Point2D::new(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<Cs>(
164    polygon: &Polygon<Point2D<f64, Cs>>,
165    distance: f64,
166    join: JoinStrategy,
167) -> Polygon<Point2D<f64, Cs>>
168where
169    Cs: CoordinateSystem<Family = CartesianFamily> + Copy,
170{
171    let mut verts: Vec<Point2D<f64, Cs>> = distinct_vertices(polygon.exterior());
172    if verts.len() < 3 {
173        return Polygon::new(Ring::new());
174    }
175
176    // Normalise winding to geometrically counter-clockwise so the
177    // right-hand normal `(dy, -dx)` is genuinely outward and the corner
178    // arc sweeps the short (convex) way. A negative *math* signed area
179    // (CCW-positive convention) means the ring is clockwise and must be
180    // reversed. Reversing a ring negates its signed area without changing
181    // the point set, so the buffered shape is unaffected.
182    if signed_area_ccw_positive(&verts) < 0.0 {
183        verts.reverse();
184    }
185
186    // The outward normal of a CCW edge points to its right. For each
187    // edge, offset both endpoints outward; at each vertex, connect the
188    // incoming and outgoing offset points with an arc (round) or their
189    // intersection (miter).
190    let n = verts.len();
191    let mut boundary: Vec<Point2D<f64, Cs>> = Vec::new();
192
193    for i in 0..n {
194        let prev = verts[(i + n - 1) % n];
195        let curr = verts[i];
196        let next = verts[(i + 1) % n];
197
198        // Outward normals of the two edges meeting at `curr`.
199        let n_in = outward_normal(&prev, &curr);
200        let n_out = outward_normal(&curr, &next);
201
202        // Offset positions of `curr` along each edge's normal.
203        let p_in = offset(&curr, &n_in, distance);
204        let p_out = offset(&curr, &n_out, distance);
205
206        boundary.push(p_in);
207        match join {
208            JoinStrategy::Round { points_per_circle } => {
209                push_corner_arc(
210                    &mut boundary,
211                    &curr,
212                    &p_in,
213                    &p_out,
214                    distance,
215                    points_per_circle.max(3),
216                );
217            }
218            JoinStrategy::Miter => {
219                // True miter: the intersection of the two offset edge
220                // lines, `curr + s · (2d / |s|²)` with `s = n_in + n_out`
221                // (`|s| = 2·cos(θ/2)`, so the point sits at
222                // `d / cos(θ/2)` along the outward bisector). Mirrors
223                // `strategy::buffer::join_miter`
224                // (`strategies/buffer/buffer_join_miter.hpp`), minus the
225                // miter_limit cap (documented on the enum variant).
226                //
227                // Degenerate guards: a zero-length edge yields a (0,0)
228                // normal and an anti-parallel pair yields `s == (0,0)`;
229                // in both cases there is no finite/meaningful miter
230                // point, so emit none — the `p_in` / `p_out` offset
231                // points already bracket the corner.
232                let n_in_ok = n_in.0 != 0.0 || n_in.1 != 0.0;
233                let n_out_ok = n_out.0 != 0.0 || n_out.1 != 0.0;
234                let sx = n_in.0 + n_out.0;
235                let sy = n_in.1 + n_out.1;
236                let len2 = sx * sx + sy * sy;
237                if n_in_ok && n_out_ok && len2 > 0.0 {
238                    let scale = 2.0 * distance / len2;
239                    boundary.push(Point2D::new(
240                        curr.get::<0>() + sx * scale,
241                        curr.get::<1>() + sy * scale,
242                    ));
243                }
244            }
245        }
246        boundary.push(p_out);
247    }
248
249    // Close the ring.
250    if let Some(first) = boundary.first().copied() {
251        boundary.push(first);
252    }
253    Polygon::new(Ring::from_vec(boundary))
254}
255
256/// A regular-polygon approximation of a circle, CCW, closed.
257fn circle_ring<Cs>(cx: f64, cy: f64, r: f64, segments: usize) -> Ring<Point2D<f64, Cs>>
258where
259    Cs: CoordinateSystem<Family = CartesianFamily> + Copy,
260{
261    let mut pts = Vec::with_capacity(segments + 1);
262    let step = core::f64::consts::TAU / segments as f64;
263    for k in 0..segments {
264        let a = step * k as f64;
265        pts.push(Point2D::new(cx + r * a.cos(), cy + r * a.sin()));
266    }
267    pts.push(pts[0]);
268    Ring::from_vec(pts)
269}
270
271/// Distinct consecutive vertices of a ring (drops the closing repeat).
272fn distinct_vertices<Cs>(ring: &Ring<Point2D<f64, Cs>>) -> Vec<Point2D<f64, Cs>>
273where
274    Cs: CoordinateSystem<Family = CartesianFamily> + Copy,
275{
276    let mut pts: Vec<Point2D<f64, Cs>> = ring.points().copied().collect();
277    if pts.len() >= 2 {
278        let first = pts[0];
279        let last = pts[pts.len() - 1];
280        if same(&first, &last) {
281            pts.pop();
282        }
283    }
284    pts
285}
286
287/// The standard math signed area of the vertex ring (counter-clockwise
288/// positive), via the shoelace sum over the closed loop. Used only to
289/// detect winding for normalisation.
290fn signed_area_ccw_positive<Cs>(verts: &[Point2D<f64, Cs>]) -> f64
291where
292    Cs: CoordinateSystem<Family = CartesianFamily> + Copy,
293{
294    let n = verts.len();
295    let mut acc = 0.0;
296    for i in 0..n {
297        let a = &verts[i];
298        let b = &verts[(i + 1) % n];
299        acc += a.get::<0>() * b.get::<1>() - b.get::<0>() * a.get::<1>();
300    }
301    acc * 0.5
302}
303
304/// The outward unit normal of the directed edge `a → b` for a CCW ring
305/// (pointing to the edge's right).
306fn outward_normal<Cs>(a: &Point2D<f64, Cs>, b: &Point2D<f64, Cs>) -> (f64, f64)
307where
308    Cs: CoordinateSystem<Family = CartesianFamily> + Copy,
309{
310    let dx = b.get::<0>() - a.get::<0>();
311    let dy = b.get::<1>() - a.get::<1>();
312    let len = (dx * dx + dy * dy).sqrt();
313    if len == 0.0 {
314        return (0.0, 0.0);
315    }
316    // Right-hand normal of (dx, dy) is (dy, -dx).
317    (dy / len, -dx / len)
318}
319
320/// `p` moved by `distance` along the unit vector `dir`.
321fn offset<Cs>(p: &Point2D<f64, Cs>, dir: &(f64, f64), distance: f64) -> Point2D<f64, Cs>
322where
323    Cs: CoordinateSystem<Family = CartesianFamily> + Copy,
324{
325    Point2D::new(
326        p.get::<0>() + dir.0 * distance,
327        p.get::<1>() + dir.1 * distance,
328    )
329}
330
331/// Push the arc that rounds a convex corner, from offset point `from`
332/// to `to`, centred on the original vertex `center` at radius
333/// `distance`.
334fn push_corner_arc<Cs>(
335    out: &mut Vec<Point2D<f64, Cs>>,
336    center: &Point2D<f64, Cs>,
337    from: &Point2D<f64, Cs>,
338    to: &Point2D<f64, Cs>,
339    distance: f64,
340    points_per_circle: usize,
341) where
342    Cs: CoordinateSystem<Family = CartesianFamily> + Copy,
343{
344    let cx = center.get::<0>();
345    let cy = center.get::<1>();
346    let a0 = (from.get::<1>() - cy).atan2(from.get::<0>() - cx);
347    let mut a1 = (to.get::<1>() - cy).atan2(to.get::<0>() - cx);
348    // Sweep the short way, counter-clockwise (positive) for a convex CCW
349    // corner.
350    while a1 < a0 {
351        a1 += core::f64::consts::TAU;
352    }
353    let sweep = a1 - a0;
354    let steps = ((sweep / core::f64::consts::TAU) * points_per_circle as f64).ceil() as usize;
355    let steps = steps.max(1);
356    for k in 1..steps {
357        let a = a0 + sweep * (k as f64 / steps as f64);
358        out.push(Point2D::new(
359            cx + distance * a.cos(),
360            cy + distance * a.sin(),
361        ));
362    }
363}
364
365fn same<Cs>(a: &Point2D<f64, Cs>, b: &Point2D<f64, Cs>) -> bool
366where
367    Cs: CoordinateSystem<Family = CartesianFamily> + Copy,
368{
369    a.get::<0>() == b.get::<0>() && a.get::<1>() == b.get::<1>()
370}
371
372#[cfg(test)]
373mod tests {
374    //! OVL7 done-when: buffered areas match the closed-form values.
375    //! Mirrors `test/algorithms/buffer/`.
376
377    use super::{JoinStrategy, PointStrategy, buffer_convex_polygon, buffer_point};
378    use geometry_algorithm::ring_area;
379    use geometry_cs::Cartesian;
380    use geometry_model::{Point2D, Polygon, polygon};
381    use geometry_trait::Polygon as _;
382
383    type P = Point2D<f64, Cartesian>;
384
385    fn close(a: f64, b: f64, tol: f64) {
386        assert!((a - b).abs() < tol, "expected {b}, got {a}");
387    }
388
389    #[test]
390    fn point_circle_area_approximates_pi_r_squared() {
391        let disc = buffer_point(
392            &P::new(0.0, 0.0),
393            2.0,
394            PointStrategy::Circle {
395                points_per_circle: 720,
396            },
397        );
398        // π·r² = π·4.
399        close(ring_area(&disc).abs(), core::f64::consts::PI * 4.0, 1e-2);
400    }
401
402    #[test]
403    fn point_square_area() {
404        let sq = buffer_point(&P::new(0.0, 0.0), 3.0, PointStrategy::Square);
405        // A square of half-side 3 → side 6 → area 36.
406        close(ring_area(&sq).abs(), 36.0, 1e-9);
407    }
408
409    #[test]
410    fn convex_square_round_buffer_area() {
411        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)]];
412        let grown = buffer_convex_polygon(
413            &sq,
414            1.0,
415            JoinStrategy::Round {
416                points_per_circle: 720,
417            },
418        );
419        // s² + 4·s·d + π·d² = 4 + 8 + π.
420        let expected = 4.0 + 8.0 + core::f64::consts::PI;
421        close(ring_area(grown.exterior()).abs(), expected, 1e-2);
422    }
423
424    #[test]
425    fn convex_triangle_round_buffer_grows() {
426        let tri: Polygon<P> = polygon![[(0.0, 0.0), (4.0, 0.0), (0.0, 3.0), (0.0, 0.0)]];
427        let base = ring_area(tri.exterior()).abs(); // 6
428        let grown = buffer_convex_polygon(
429            &tri,
430            0.5,
431            JoinStrategy::Round {
432                points_per_circle: 360,
433            },
434        );
435        // The buffered area must exceed the original.
436        assert!(ring_area(grown.exterior()).abs() > base);
437    }
438
439    #[test]
440    fn buffer_is_winding_independent() {
441        // Regression: the same square listed clockwise and counter-
442        // clockwise must buffer to the same grown area. The winding
443        // normalisation makes the outward offset direction correct for
444        // both.
445        let ccw: Polygon<P> =
446            polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
447        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)]];
448        let j = JoinStrategy::Round {
449            points_per_circle: 720,
450        };
451        let expected = 4.0 + 8.0 + core::f64::consts::PI;
452        let grown_from_counterclockwise =
453            ring_area(buffer_convex_polygon(&ccw, 1.0, j).exterior()).abs();
454        let grown_from_clockwise = ring_area(buffer_convex_polygon(&cw, 1.0, j).exterior()).abs();
455        close(grown_from_counterclockwise, expected, 5e-2);
456        close(grown_from_clockwise, expected, 5e-2);
457    }
458
459    #[test]
460    fn miter_square_area_is_16() {
461        // Regression: the old Miter arm placed the corner point at
462        // distance d along the bisector (ON the round arc), yielding
463        // 14.83 — smaller than even the round buffer. A true miter
464        // corner is the offset-edge intersection at √2·d, so the
465        // buffered 2×2 square is s² + 4·s·d + 4·d² = 16 exactly.
466        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)]];
467        let grown = buffer_convex_polygon(&sq, 1.0, JoinStrategy::Miter);
468        close(ring_area(grown.exterior()).abs(), 16.0, 1e-9);
469    }
470
471    #[test]
472    fn miter_contains_near_corner_probe() {
473        // A point at distance 0.99 < d from the input corner, in the
474        // 22.5° direction, was EXCLUDED by the old chord-cut corner.
475        use geometry_algorithm::within;
476        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)]];
477        let grown = buffer_convex_polygon(&sq, 1.0, JoinStrategy::Miter);
478        let ang = 22.5_f64.to_radians();
479        let probe = P::new(2.0 + 0.99 * ang.cos(), 2.0 + 0.99 * ang.sin());
480        assert!(
481            within(&probe, &grown),
482            "buffer must contain points within d"
483        );
484    }
485
486    #[test]
487    fn miter_is_superset_of_round_by_area() {
488        // A miter fills the wedge beyond the round arc, so its area
489        // can never be below the round join's.
490        let j_round = JoinStrategy::Round {
491            points_per_circle: 720,
492        };
493        let square: Polygon<P> =
494            polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
495        let triangle: Polygon<P> = polygon![[(0.0, 0.0), (4.0, 0.0), (0.0, 3.0), (0.0, 0.0)]];
496        for pg in [square, triangle] {
497            let m =
498                ring_area(buffer_convex_polygon(&pg, 1.0, JoinStrategy::Miter).exterior()).abs();
499            let r = ring_area(buffer_convex_polygon(&pg, 1.0, j_round).exterior()).abs();
500            assert!(m >= r - 1e-9, "miter {m} must not be below round {r}");
501        }
502    }
503
504    #[test]
505    fn miter_is_winding_independent() {
506        // Same square listed CW and CCW buffers to the same miter area.
507        let ccw: Polygon<P> =
508            polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
509        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)]];
510        close(
511            ring_area(buffer_convex_polygon(&ccw, 1.0, JoinStrategy::Miter).exterior()).abs(),
512            16.0,
513            1e-9,
514        );
515        close(
516            ring_area(buffer_convex_polygon(&cw, 1.0, JoinStrategy::Miter).exterior()).abs(),
517            16.0,
518            1e-9,
519        );
520    }
521}