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}