oxigdal_algorithms/vector/voronoi.rs
1//! Voronoi diagram generation
2//!
3//! Compute Voronoi diagrams (Thiessen polygons) from point sets.
4
5use crate::error::{AlgorithmError, Result};
6use oxigdal_core::vector::{Coordinate, LineString, Point, Polygon};
7
8/// Options for Voronoi diagram generation
9#[derive(Debug, Clone, Default)]
10pub struct VoronoiOptions {
11 /// Bounding box for clipping diagram
12 pub bounds: Option<(f64, f64, f64, f64)>, // (min_x, min_y, max_x, max_y)
13 /// Whether to include infinite cells
14 pub include_infinite: bool,
15}
16
17/// A Voronoi cell (polygon)
18#[derive(Debug, Clone)]
19pub struct VoronoiCell {
20 /// Site (generating point) for this cell
21 pub site: Point,
22 /// Site index
23 pub site_index: usize,
24 /// Polygon representing the cell
25 pub polygon: Option<Polygon>,
26 /// Whether this is an infinite cell
27 pub is_infinite: bool,
28}
29
30/// Voronoi diagram result
31#[derive(Debug, Clone)]
32pub struct VoronoiDiagram {
33 /// Voronoi cells
34 pub cells: Vec<VoronoiCell>,
35 /// Number of sites
36 pub num_sites: usize,
37}
38
39/// Generate Voronoi diagram from points
40///
41/// # Arguments
42///
43/// * `points` - Input points (sites)
44/// * `options` - Voronoi options
45///
46/// # Returns
47///
48/// Voronoi diagram with cells for each point
49///
50/// # Examples
51///
52/// ```
53/// use oxigdal_algorithms::vector::voronoi::{voronoi_diagram, VoronoiOptions};
54/// use oxigdal_algorithms::Point;
55/// # use oxigdal_algorithms::error::Result;
56///
57/// # fn main() -> Result<()> {
58/// let points = vec![
59/// Point::new(0.0, 0.0),
60/// Point::new(5.0, 0.0),
61/// Point::new(2.5, 5.0),
62/// ];
63///
64/// let options = VoronoiOptions {
65/// bounds: Some((0.0, 0.0, 10.0, 10.0)),
66/// include_infinite: false,
67/// };
68///
69/// let diagram = voronoi_diagram(&points, &options)?;
70/// assert_eq!(diagram.num_sites, 3);
71/// # Ok(())
72/// # }
73/// ```
74pub fn voronoi_diagram(points: &[Point], options: &VoronoiOptions) -> Result<VoronoiDiagram> {
75 if points.len() < 3 {
76 return Err(AlgorithmError::InvalidInput(
77 "Need at least 3 points for Voronoi diagram".to_string(),
78 ));
79 }
80
81 // Convert points to delaunator format
82 let delaunator_points: Vec<delaunator::Point> = points
83 .iter()
84 .map(|p| delaunator::Point {
85 x: p.coord.x,
86 y: p.coord.y,
87 })
88 .collect();
89
90 // Compute Delaunay triangulation
91 let delaunay = delaunator::triangulate(&delaunator_points);
92
93 // Build Voronoi cells from dual graph
94 let mut cells = Vec::new();
95
96 for (site_idx, point) in points.iter().enumerate() {
97 let cell = build_voronoi_cell(site_idx, point, &delaunay, points, options)?;
98 cells.push(cell);
99 }
100
101 Ok(VoronoiDiagram {
102 cells,
103 num_sites: points.len(),
104 })
105}
106
107/// Build a Voronoi cell for a site
108fn build_voronoi_cell(
109 site_idx: usize,
110 site: &Point,
111 delaunay: &delaunator::Triangulation,
112 points: &[Point],
113 options: &VoronoiOptions,
114) -> Result<VoronoiCell> {
115 // Find all triangles containing this site
116 let mut cell_vertices = Vec::new();
117 let mut is_infinite = false;
118
119 // Compute circumcenters of triangles
120 for tri_idx in 0..(delaunay.triangles.len() / 3) {
121 let a = delaunay.triangles[tri_idx * 3];
122 let b = delaunay.triangles[tri_idx * 3 + 1];
123 let c = delaunay.triangles[tri_idx * 3 + 2];
124
125 if a == site_idx || b == site_idx || c == site_idx {
126 // This triangle contains our site
127 let pa = &points[a];
128 let pb = &points[b];
129 let pc = &points[c];
130
131 let circumcenter = compute_circumcenter(
132 pa.coord.x, pa.coord.y, pb.coord.x, pb.coord.y, pc.coord.x, pc.coord.y,
133 )?;
134
135 cell_vertices.push(circumcenter);
136 }
137 }
138
139 // Check if cell is bounded
140 if let Some((min_x, min_y, max_x, max_y)) = options.bounds {
141 // Clip vertices to bounds
142 cell_vertices.retain(|coord| {
143 coord.x >= min_x && coord.x <= max_x && coord.y >= min_y && coord.y <= max_y
144 });
145
146 is_infinite = cell_vertices.len() < 3;
147 }
148
149 // Create polygon from vertices
150 let polygon =
151 if cell_vertices.len() >= 3 {
152 // Sort vertices by angle around site
153 cell_vertices.sort_by(|a, b| {
154 let angle_a = (a.y - site.coord.y).atan2(a.x - site.coord.x);
155 let angle_b = (b.y - site.coord.y).atan2(b.x - site.coord.x);
156 angle_a
157 .partial_cmp(&angle_b)
158 .unwrap_or(std::cmp::Ordering::Equal)
159 });
160
161 // Close the ring
162 if let Some(first) = cell_vertices.first().copied() {
163 cell_vertices.push(first);
164 }
165
166 // Create polygon
167 let exterior = LineString::new(cell_vertices.clone()).map_err(|e| {
168 AlgorithmError::InvalidGeometry(format!("Invalid cell exterior: {}", e))
169 })?;
170 Some(Polygon::new(exterior, vec![]).map_err(|e| {
171 AlgorithmError::InvalidGeometry(format!("Invalid cell polygon: {}", e))
172 })?)
173 } else {
174 None
175 };
176
177 Ok(VoronoiCell {
178 site: site.clone(),
179 site_index: site_idx,
180 polygon,
181 is_infinite,
182 })
183}
184
185/// Compute circumcenter of a triangle
186fn compute_circumcenter(
187 ax: f64,
188 ay: f64,
189 bx: f64,
190 by: f64,
191 cx: f64,
192 cy: f64,
193) -> Result<Coordinate> {
194 let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
195
196 if d.abs() < 1e-10 {
197 return Err(AlgorithmError::ComputationError(
198 "Degenerate triangle".to_string(),
199 ));
200 }
201
202 let ux = ((ax * ax + ay * ay) * (by - cy)
203 + (bx * bx + by * by) * (cy - ay)
204 + (cx * cx + cy * cy) * (ay - by))
205 / d;
206 let uy = ((ax * ax + ay * ay) * (cx - bx)
207 + (bx * bx + by * by) * (ax - cx)
208 + (cx * cx + cy * cy) * (bx - ax))
209 / d;
210
211 Ok(Coordinate::new_2d(ux, uy))
212}
213
214// ============================================================================
215// Power Diagram (Weighted Voronoi) — Aurenhammer 1987
216// ============================================================================
217//
218// A power diagram generalises the Voronoi diagram: each site p_i has a weight
219// w_i. The *power distance* from a query point x to site i is:
220//
221// pd(x, p_i) = |x - p_i|² − w_i
222//
223// The power cell of site i is the set of all x for which pd(x, p_i) is
224// minimal. When all weights are equal the diagram reduces to the standard
225// Voronoi diagram.
226//
227// Implementation: half-plane intersection via Sutherland–Hodgman clipping.
228// Each cell starts as the bounding rectangle and is successively clipped by
229// the radical-axis half-plane for every other site. Time: O(N² · V) where V
230// is the vertex count of the intermediate polygon (at most O(N)).
231
232/// A weighted site for power diagram construction.
233///
234/// The `weight` field acts as the squared radius of an influence circle centred
235/// at `point`. A larger weight enlarges the power cell of that site.
236#[derive(Debug, Clone)]
237pub struct WeightedPoint {
238 /// The geometric location of the site.
239 pub point: Point,
240 /// Power weight (equivalent to squared circle radius). Use 0.0 for
241 /// unweighted sites — the result is then a standard Voronoi cell.
242 pub weight: f64,
243}
244
245impl WeightedPoint {
246 /// Create a new weighted site.
247 pub fn new(x: f64, y: f64, weight: f64) -> Self {
248 Self {
249 point: Point::new(x, y),
250 weight,
251 }
252 }
253
254 /// Create a site with weight 0.0 (equivalent to a standard Voronoi site).
255 pub fn unweighted(x: f64, y: f64) -> Self {
256 Self::new(x, y, 0.0)
257 }
258}
259
260/// One cell of a power diagram.
261#[derive(Debug, Clone)]
262pub struct PowerCell {
263 /// Index of the generating site in the input slice.
264 pub site_index: usize,
265 /// Vertices of the cell polygon in counter-clockwise order.
266 /// Empty when the site is dominated by its neighbours (see `is_empty`).
267 pub polygon: Vec<Coordinate>,
268 /// `true` when every point in the bounding box is closer (in power
269 /// distance) to some other site, so this site's cell has zero area.
270 pub is_empty: bool,
271}
272
273/// The complete result of `power_diagram`.
274#[derive(Debug, Clone)]
275pub struct PowerDiagram {
276 /// One `PowerCell` per input site, in the same order as the input slice.
277 pub cells: Vec<PowerCell>,
278}
279
280/// Options controlling `power_diagram`.
281#[derive(Debug, Clone)]
282pub struct PowerDiagramOptions {
283 /// Explicit clipping rectangle `(min_x, min_y, max_x, max_y)`.
284 ///
285 /// If `None` the bounding box is derived from the input point extents
286 /// expanded by 50 % on each side (plus an absolute minimum padding of
287 /// 1.0 unit so single-point inputs get a sensible box).
288 pub bounding_box: Option<(f64, f64, f64, f64)>,
289}
290
291impl Default for PowerDiagramOptions {
292 fn default() -> Self {
293 Self { bounding_box: None }
294 }
295}
296
297/// Compute a power diagram (weighted Voronoi diagram) from a set of weighted
298/// point sites.
299///
300/// # Algorithm
301///
302/// For each site `i` the cell is constructed by starting with the bounding
303/// rectangle and successively clipping it with the radical-axis half-plane
304/// defined by the pair `(i, j)` for every other site `j ≠ i`. The radical
305/// axis is the locus of points equidistant (in power distance) from both
306/// sites:
307///
308/// ```text
309/// 2(xj−xi)·x + 2(yj−yi)·y = (xj²+yj²−wj) − (xi²+yi²−wi)
310/// ```
311///
312/// Points satisfying `a·x + b·y ≤ c` belong to site `i`'s side.
313///
314/// # Performance
315///
316/// O(N² · V) where V ≤ N+4 is the polygon vertex count. Suitable for
317/// N < 1 000 sites; for larger inputs consider a fortune-sweep approach.
318///
319/// # Examples
320///
321/// ```
322/// use oxigdal_algorithms::{power_diagram, WeightedPoint, PowerDiagramOptions};
323///
324/// let sites = vec![
325/// WeightedPoint::new(0.0, 0.0, 0.0),
326/// WeightedPoint::new(4.0, 0.0, 0.0),
327/// ];
328/// let opts = PowerDiagramOptions {
329/// bounding_box: Some((-1.0, -1.0, 5.0, 1.0)),
330/// };
331/// let diagram = power_diagram(&sites, &opts).unwrap();
332/// assert_eq!(diagram.cells.len(), 2);
333/// ```
334pub fn power_diagram(
335 weighted_points: &[WeightedPoint],
336 options: &PowerDiagramOptions,
337) -> crate::error::Result<PowerDiagram> {
338 if weighted_points.is_empty() {
339 return Ok(PowerDiagram { cells: vec![] });
340 }
341
342 let bbox = compute_power_bbox(weighted_points, options);
343
344 if weighted_points.len() == 1 {
345 // Single site: the entire bounding box is the cell.
346 let polygon = bbox_to_polygon(bbox);
347 return Ok(PowerDiagram {
348 cells: vec![PowerCell {
349 site_index: 0,
350 polygon,
351 is_empty: false,
352 }],
353 });
354 }
355
356 let mut cells = Vec::with_capacity(weighted_points.len());
357
358 for i in 0..weighted_points.len() {
359 let pi = &weighted_points[i];
360 // Start each cell as the full bounding box.
361 let mut polygon = bbox_to_polygon(bbox);
362
363 for (j, pj) in weighted_points.iter().enumerate() {
364 if i == j || polygon.is_empty() {
365 continue;
366 }
367 // weighted_bisector returns (a,b,c) where a·x+b·y ≤ c is site i's
368 // side. Negate to get the ≥ form that half_plane_clip expects.
369 let (a, b, c) = weighted_bisector(
370 pi.point.coord.x,
371 pi.point.coord.y,
372 pi.weight,
373 pj.point.coord.x,
374 pj.point.coord.y,
375 pj.weight,
376 );
377 polygon = half_plane_clip(&polygon, -a, -b, -c);
378 }
379
380 let is_empty = polygon.is_empty();
381 cells.push(PowerCell {
382 site_index: i,
383 polygon,
384 is_empty,
385 });
386 }
387
388 Ok(PowerDiagram { cells })
389}
390
391// ----------------------------------------------------------------------------
392// Public helper — useful for testing and for custom diagram implementations.
393// ----------------------------------------------------------------------------
394
395/// Compute the radical-axis half-plane coefficients `(a, b, c)` such that
396/// `a·x + b·y ≤ c` selects the region closer (in power distance) to site
397/// `(xi, yi, wi)` than to site `(xj, yj, wj)`.
398///
399/// Derivation of the radical axis (bisector locus):
400/// ```text
401/// pd(x, pi) ≤ pd(x, pj)
402/// ⟺ |x−pi|² − wi ≤ |x−pj|² − wj
403/// ⟺ −2xi·x + xi² − 2yi·y + yi² − wi ≤ −2xj·x + xj² − 2yj·y + yj² − wj
404/// ⟺ 2(xj−xi)·x + 2(yj−yi)·y ≤ (xj²+yj²−wj) − (xi²+yi²−wi)
405/// ⟺ a·x + b·y ≤ c
406/// ```
407///
408/// The bisector (radical axis) itself is `a·x + b·y = c`.
409/// The `≤` side contains site `i`; the `≥` side contains site `j`.
410///
411/// # Note
412///
413/// Call `half_plane_clip` with `(-a, -b, -c)` to use the standard `≥` form,
414/// or use `weighted_bisector_ge` which returns negated coefficients directly.
415pub fn weighted_bisector(xi: f64, yi: f64, wi: f64, xj: f64, yj: f64, wj: f64) -> (f64, f64, f64) {
416 let a = 2.0 * (xj - xi);
417 let b = 2.0 * (yj - yi);
418 // RHS of the radical-axis inequality (a·x + b·y ≤ c keeps pi's side)
419 let c = (xj * xj + yj * yj - wj) - (xi * xi + yi * yi - wi);
420 (a, b, c)
421}
422
423// ----------------------------------------------------------------------------
424// Private helpers
425// ----------------------------------------------------------------------------
426
427/// Derive a bounding box from the input sites, adding 50 % padding plus a
428/// minimum absolute pad of 1.0 unit on each axis, unless an explicit box was
429/// supplied in `options`.
430fn compute_power_bbox(
431 points: &[WeightedPoint],
432 options: &PowerDiagramOptions,
433) -> (f64, f64, f64, f64) {
434 if let Some(bbox) = options.bounding_box {
435 return bbox;
436 }
437 let mut min_x = f64::INFINITY;
438 let mut max_x = f64::NEG_INFINITY;
439 let mut min_y = f64::INFINITY;
440 let mut max_y = f64::NEG_INFINITY;
441 for wp in points {
442 let x = wp.point.coord.x;
443 let y = wp.point.coord.y;
444 if x < min_x {
445 min_x = x;
446 }
447 if x > max_x {
448 max_x = x;
449 }
450 if y < min_y {
451 min_y = y;
452 }
453 if y > max_y {
454 max_y = y;
455 }
456 }
457 let pad_x = (max_x - min_x) * 0.5 + 1.0;
458 let pad_y = (max_y - min_y) * 0.5 + 1.0;
459 (min_x - pad_x, min_y - pad_y, max_x + pad_x, max_y + pad_y)
460}
461
462/// Convert a bounding box to a CCW polygon (four corners, no closing vertex).
463fn bbox_to_polygon(bbox: (f64, f64, f64, f64)) -> Vec<Coordinate> {
464 let (min_x, min_y, max_x, max_y) = bbox;
465 vec![
466 Coordinate::new_2d(min_x, min_y),
467 Coordinate::new_2d(max_x, min_y),
468 Coordinate::new_2d(max_x, max_y),
469 Coordinate::new_2d(min_x, max_y),
470 ]
471}
472
473/// Clip a convex polygon against the half-plane `a·x + b·y ≥ c` using
474/// the Sutherland–Hodgman algorithm.
475///
476/// Returns the vertices of the clipped polygon (counter-clockwise).
477/// Returns an empty `Vec` if the polygon is entirely outside the half-plane.
478fn half_plane_clip(polygon: &[Coordinate], a: f64, b: f64, c: f64) -> Vec<Coordinate> {
479 if polygon.is_empty() {
480 return vec![];
481 }
482
483 let inside = |p: &Coordinate| a * p.x + b * p.y >= c;
484
485 let n = polygon.len();
486 let mut output: Vec<Coordinate> = Vec::with_capacity(n + 1);
487
488 for i in 0..n {
489 let curr = &polygon[i];
490 let next = &polygon[(i + 1) % n];
491 let curr_in = inside(curr);
492 let next_in = inside(next);
493
494 if curr_in {
495 output.push(*curr);
496 }
497 if curr_in != next_in {
498 // Compute the parametric intersection point.
499 let t = intersect_segment_with_halfplane(curr, next, a, b, c);
500 output.push(Coordinate::new_2d(
501 curr.x + t * (next.x - curr.x),
502 curr.y + t * (next.y - curr.y),
503 ));
504 }
505 }
506 output
507}
508
509/// Return the parametric `t ∈ [0, 1]` at which the segment `[p1, p2]`
510/// crosses the plane `a·x + b·y = c`.
511///
512/// Falls back to `0.5` for degenerate (parallel) cases that should not
513/// occur in well-formed input.
514fn intersect_segment_with_halfplane(
515 p1: &Coordinate,
516 p2: &Coordinate,
517 a: f64,
518 b: f64,
519 c: f64,
520) -> f64 {
521 let d1 = a * p1.x + b * p1.y - c;
522 let d2 = a * p2.x + b * p2.y - c;
523 let denom = d1 - d2;
524 if denom.abs() < f64::EPSILON {
525 return 0.5;
526 }
527 d1 / denom
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn test_voronoi_simple() {
536 let points = vec![
537 Point::new(0.0, 0.0),
538 Point::new(5.0, 0.0),
539 Point::new(2.5, 5.0),
540 ];
541
542 let options = VoronoiOptions {
543 bounds: Some((0.0, 0.0, 10.0, 10.0)),
544 include_infinite: false,
545 };
546
547 let result = voronoi_diagram(&points, &options);
548 assert!(result.is_ok());
549
550 let diagram = result.expect("Voronoi failed");
551 assert_eq!(diagram.num_sites, 3);
552 }
553
554 #[test]
555 fn test_circumcenter() {
556 let result = compute_circumcenter(0.0, 0.0, 1.0, 0.0, 0.0, 1.0);
557 assert!(result.is_ok());
558
559 let center = result.expect("Failed to compute circumcenter");
560 assert!((center.x - 0.5).abs() < 1e-6);
561 assert!((center.y - 0.5).abs() < 1e-6);
562 }
563}