Skip to main content

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}