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}