Skip to main content

oxigdal_algorithms/vector/
centroid.rs

1//! Centroid calculation for geometric features
2//!
3//! This module provides functions for computing centroids (geometric centers)
4//! of various geometry types using different methods.
5//!
6//! # Centroid Types
7//!
8//! - **Geometric Centroid**: Simple average of coordinates (for points and lines)
9//! - **Area-Weighted Centroid**: Center of mass for polygons (accounts for area distribution)
10//! - **3D Centroid**: Centroid calculation including Z coordinates
11//!
12//! # Examples
13//!
14//! ```
15//! use oxigdal_algorithms::vector::{Coordinate, Polygon, LineString, centroid_polygon};
16//!
17//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
18//! let coords = vec![
19//!     Coordinate::new_2d(0.0, 0.0),
20//!     Coordinate::new_2d(4.0, 0.0),
21//!     Coordinate::new_2d(4.0, 4.0),
22//!     Coordinate::new_2d(0.0, 4.0),
23//!     Coordinate::new_2d(0.0, 0.0),
24//! ];
25//! let exterior = LineString::new(coords)?;
26//! let polygon = Polygon::new(exterior, vec![])?;
27//! let center = centroid_polygon(&polygon)?;
28//! // Center should be at (2.0, 2.0)
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::error::{AlgorithmError, Result};
34use oxigdal_core::vector::{
35    Coordinate, Geometry, GeometryCollection, LineString, MultiLineString, MultiPoint,
36    MultiPolygon, Point, Polygon,
37};
38
39/// Computes the centroid of any geometry type
40///
41/// # Arguments
42///
43/// * `geometry` - Input geometry
44///
45/// # Returns
46///
47/// Point representing the centroid
48///
49/// # Errors
50///
51/// Returns error if geometry is empty or invalid
52pub fn centroid(geometry: &Geometry) -> Result<Point> {
53    match geometry {
54        Geometry::Point(p) => Ok(p.clone()),
55        Geometry::LineString(ls) => centroid_linestring(ls),
56        Geometry::Polygon(p) => centroid_polygon(p),
57        Geometry::MultiPoint(mp) => centroid_multipoint(mp),
58        Geometry::MultiLineString(mls) => centroid_multilinestring(mls),
59        Geometry::MultiPolygon(mp) => centroid_multipolygon(mp),
60        Geometry::GeometryCollection(gc) => centroid_collection(gc),
61    }
62}
63
64/// Computes the centroid of a point (returns the point itself)
65///
66/// # Arguments
67///
68/// * `point` - Input point
69///
70/// # Returns
71///
72/// The point itself as its own centroid
73pub fn centroid_point(point: &Point) -> Point {
74    point.clone()
75}
76
77/// Computes the geometric centroid of a linestring
78///
79/// The centroid is computed as the weighted average of coordinates,
80/// where weights are the lengths of segments ending at each coordinate.
81///
82/// # Arguments
83///
84/// * `linestring` - Input linestring
85///
86/// # Returns
87///
88/// Point representing the centroid
89///
90/// # Errors
91///
92/// Returns error if linestring is empty
93pub fn centroid_linestring(linestring: &LineString) -> Result<Point> {
94    if linestring.coords.is_empty() {
95        return Err(AlgorithmError::EmptyInput {
96            operation: "centroid_linestring",
97        });
98    }
99
100    if linestring.coords.len() == 1 {
101        return Ok(Point::from_coord(linestring.coords[0]));
102    }
103
104    let mut total_length = 0.0;
105    let mut weighted_x = 0.0;
106    let mut weighted_y = 0.0;
107    let mut weighted_z = 0.0;
108    let has_z = linestring.coords[0].has_z();
109
110    for i in 0..linestring.coords.len() - 1 {
111        let p1 = &linestring.coords[i];
112        let p2 = &linestring.coords[i + 1];
113
114        let dx = p2.x - p1.x;
115        let dy = p2.y - p1.y;
116        let dz = if has_z {
117            p2.z.unwrap_or(0.0) - p1.z.unwrap_or(0.0)
118        } else {
119            0.0
120        };
121
122        let length = (dx * dx + dy * dy + dz * dz).sqrt();
123
124        if length > 0.0 {
125            // Use midpoint of segment weighted by length
126            let mid_x = (p1.x + p2.x) / 2.0;
127            let mid_y = (p1.y + p2.y) / 2.0;
128
129            weighted_x += mid_x * length;
130            weighted_y += mid_y * length;
131
132            if has_z {
133                let mid_z = (p1.z.unwrap_or(0.0) + p2.z.unwrap_or(0.0)) / 2.0;
134                weighted_z += mid_z * length;
135            }
136
137            total_length += length;
138        }
139    }
140
141    if total_length == 0.0 {
142        // All points are the same - return the first point
143        return Ok(Point::from_coord(linestring.coords[0]));
144    }
145
146    let centroid_coord = if has_z {
147        Coordinate::new_3d(
148            weighted_x / total_length,
149            weighted_y / total_length,
150            weighted_z / total_length,
151        )
152    } else {
153        Coordinate::new_2d(weighted_x / total_length, weighted_y / total_length)
154    };
155
156    Ok(Point::from_coord(centroid_coord))
157}
158
159/// Computes the area-weighted centroid of a polygon
160///
161/// Uses the signed area method to compute the true geometric centroid
162/// accounting for the area distribution within the polygon.
163///
164/// # Arguments
165///
166/// * `polygon` - Input polygon
167///
168/// # Returns
169///
170/// Point representing the centroid
171///
172/// # Errors
173///
174/// Returns error if polygon is invalid or has zero area
175pub fn centroid_polygon(polygon: &Polygon) -> Result<Point> {
176    if polygon.exterior.coords.len() < 3 {
177        return Err(AlgorithmError::InsufficientData {
178            operation: "centroid_polygon",
179            message: "polygon must have at least 3 coordinates".to_string(),
180        });
181    }
182
183    // Compute centroid of exterior ring
184    let (ext_area, ext_cx, ext_cy) = ring_centroid(&polygon.exterior.coords)?;
185
186    if ext_area.abs() < f64::EPSILON {
187        return Err(AlgorithmError::GeometryError {
188            message: "polygon has zero area".to_string(),
189        });
190    }
191
192    let mut total_area = ext_area;
193    let mut weighted_x = ext_cx * ext_area;
194    let mut weighted_y = ext_cy * ext_area;
195
196    // Subtract hole contributions
197    for hole in &polygon.interiors {
198        let (hole_area, hole_cx, hole_cy) = ring_centroid(&hole.coords)?;
199
200        total_area -= hole_area;
201        weighted_x -= hole_cx * hole_area;
202        weighted_y -= hole_cy * hole_area;
203    }
204
205    if total_area.abs() < f64::EPSILON {
206        return Err(AlgorithmError::GeometryError {
207            message: "polygon has zero effective area after holes".to_string(),
208        });
209    }
210
211    let centroid_coord = Coordinate::new_2d(weighted_x / total_area, weighted_y / total_area);
212
213    Ok(Point::from_coord(centroid_coord))
214}
215
216/// Computes the centroid of a multipoint
217///
218/// # Arguments
219///
220/// * `multipoint` - Input multipoint
221///
222/// # Returns
223///
224/// Point representing the average of all points
225///
226/// # Errors
227///
228/// Returns error if multipoint is empty
229pub fn centroid_multipoint(multipoint: &MultiPoint) -> Result<Point> {
230    if multipoint.points.is_empty() {
231        return Err(AlgorithmError::EmptyInput {
232            operation: "centroid_multipoint",
233        });
234    }
235
236    let mut sum_x = 0.0;
237    let mut sum_y = 0.0;
238    let mut sum_z = 0.0;
239    let has_z = multipoint.points[0].coord.has_z();
240
241    for point in &multipoint.points {
242        sum_x += point.coord.x;
243        sum_y += point.coord.y;
244
245        if has_z {
246            sum_z += point.coord.z.unwrap_or(0.0);
247        }
248    }
249
250    let n = multipoint.points.len() as f64;
251    let centroid_coord = if has_z {
252        Coordinate::new_3d(sum_x / n, sum_y / n, sum_z / n)
253    } else {
254        Coordinate::new_2d(sum_x / n, sum_y / n)
255    };
256
257    Ok(Point::from_coord(centroid_coord))
258}
259
260/// Computes the centroid of a multilinestring
261///
262/// # Arguments
263///
264/// * `multilinestring` - Input multilinestring
265///
266/// # Returns
267///
268/// Point representing the weighted centroid of all linestrings
269///
270/// # Errors
271///
272/// Returns error if multilinestring is empty
273pub fn centroid_multilinestring(multilinestring: &MultiLineString) -> Result<Point> {
274    if multilinestring.line_strings.is_empty() {
275        return Err(AlgorithmError::EmptyInput {
276            operation: "centroid_multilinestring",
277        });
278    }
279
280    let mut total_length = 0.0;
281    let mut weighted_x = 0.0;
282    let mut weighted_y = 0.0;
283
284    for linestring in &multilinestring.line_strings {
285        let ls_centroid = centroid_linestring(linestring)?;
286        let length = linestring_length(linestring);
287
288        weighted_x += ls_centroid.coord.x * length;
289        weighted_y += ls_centroid.coord.y * length;
290        total_length += length;
291    }
292
293    if total_length == 0.0 {
294        return Err(AlgorithmError::GeometryError {
295            message: "multilinestring has zero total length".to_string(),
296        });
297    }
298
299    let centroid_coord = Coordinate::new_2d(weighted_x / total_length, weighted_y / total_length);
300
301    Ok(Point::from_coord(centroid_coord))
302}
303
304/// Computes the centroid of a multipolygon
305///
306/// # Arguments
307///
308/// * `multipolygon` - Input multipolygon
309///
310/// # Returns
311///
312/// Point representing the area-weighted centroid of all polygons
313///
314/// # Errors
315///
316/// Returns error if multipolygon is empty or has zero total area
317pub fn centroid_multipolygon(multipolygon: &MultiPolygon) -> Result<Point> {
318    if multipolygon.polygons.is_empty() {
319        return Err(AlgorithmError::EmptyInput {
320            operation: "centroid_multipolygon",
321        });
322    }
323
324    let mut total_area = 0.0;
325    let mut weighted_x = 0.0;
326    let mut weighted_y = 0.0;
327
328    for polygon in &multipolygon.polygons {
329        let poly_centroid = centroid_polygon(polygon)?;
330        let area = polygon_area(polygon);
331
332        weighted_x += poly_centroid.coord.x * area;
333        weighted_y += poly_centroid.coord.y * area;
334        total_area += area;
335    }
336
337    if total_area.abs() < f64::EPSILON {
338        return Err(AlgorithmError::GeometryError {
339            message: "multipolygon has zero total area".to_string(),
340        });
341    }
342
343    let centroid_coord = Coordinate::new_2d(weighted_x / total_area, weighted_y / total_area);
344
345    Ok(Point::from_coord(centroid_coord))
346}
347
348/// Computes the centroid of a geometry collection
349///
350/// # Arguments
351///
352/// * `collection` - Input geometry collection
353///
354/// # Returns
355///
356/// Point representing the centroid of all geometries
357///
358/// # Errors
359///
360/// Returns error if collection is empty
361pub fn centroid_collection(collection: &GeometryCollection) -> Result<Point> {
362    if collection.geometries.is_empty() {
363        return Err(AlgorithmError::EmptyInput {
364            operation: "centroid_collection",
365        });
366    }
367
368    // Compute weighted centroid based on geometry types
369    let mut total_weight = 0.0;
370    let mut weighted_x = 0.0;
371    let mut weighted_y = 0.0;
372
373    for geometry in &collection.geometries {
374        let geom_centroid = centroid(geometry)?;
375        let weight = geometry_weight(geometry);
376
377        weighted_x += geom_centroid.coord.x * weight;
378        weighted_y += geom_centroid.coord.y * weight;
379        total_weight += weight;
380    }
381
382    if total_weight == 0.0 {
383        return Err(AlgorithmError::GeometryError {
384            message: "collection has zero total weight".to_string(),
385        });
386    }
387
388    let centroid_coord = Coordinate::new_2d(weighted_x / total_weight, weighted_y / total_weight);
389
390    Ok(Point::from_coord(centroid_coord))
391}
392
393/// Computes the centroid and area of a polygon ring
394fn ring_centroid(coords: &[Coordinate]) -> Result<(f64, f64, f64)> {
395    if coords.len() < 3 {
396        return Err(AlgorithmError::InsufficientData {
397            operation: "ring_centroid",
398            message: "ring must have at least 3 coordinates".to_string(),
399        });
400    }
401
402    let mut area = 0.0;
403    let mut cx = 0.0;
404    let mut cy = 0.0;
405
406    let n = coords.len();
407    for i in 0..n {
408        let j = (i + 1) % n;
409        let cross = coords[i].x * coords[j].y - coords[j].x * coords[i].y;
410
411        area += cross;
412        cx += (coords[i].x + coords[j].x) * cross;
413        cy += (coords[i].y + coords[j].y) * cross;
414    }
415
416    area /= 2.0;
417
418    if area.abs() < f64::EPSILON {
419        // Degenerate polygon - use simple average
420        let mut sum_x = 0.0;
421        let mut sum_y = 0.0;
422        for coord in coords {
423            sum_x += coord.x;
424            sum_y += coord.y;
425        }
426        let n = coords.len() as f64;
427        return Ok((0.0, sum_x / n, sum_y / n));
428    }
429
430    cx /= 6.0 * area;
431    cy /= 6.0 * area;
432
433    Ok((area, cx, cy))
434}
435
436/// Computes the length of a linestring
437fn linestring_length(linestring: &LineString) -> f64 {
438    let mut length = 0.0;
439
440    for i in 0..linestring.coords.len().saturating_sub(1) {
441        let p1 = &linestring.coords[i];
442        let p2 = &linestring.coords[i + 1];
443
444        let dx = p2.x - p1.x;
445        let dy = p2.y - p1.y;
446
447        length += (dx * dx + dy * dy).sqrt();
448    }
449
450    length
451}
452
453/// Computes the area of a polygon
454fn polygon_area(polygon: &Polygon) -> f64 {
455    let mut area = ring_area(&polygon.exterior.coords);
456
457    for hole in &polygon.interiors {
458        area -= ring_area(&hole.coords);
459    }
460
461    area.abs()
462}
463
464/// Computes the signed area of a ring
465fn ring_area(coords: &[Coordinate]) -> f64 {
466    if coords.len() < 3 {
467        return 0.0;
468    }
469
470    let mut area = 0.0;
471    let n = coords.len();
472
473    for i in 0..n {
474        let j = (i + 1) % n;
475        area += coords[i].x * coords[j].y;
476        area -= coords[j].x * coords[i].y;
477    }
478
479    area / 2.0
480}
481
482/// Assigns a weight to a geometry for weighted centroid calculation
483fn geometry_weight(geometry: &Geometry) -> f64 {
484    match geometry {
485        Geometry::Point(_) => 1.0,
486        Geometry::LineString(ls) => linestring_length(ls).max(1.0),
487        Geometry::Polygon(p) => polygon_area(p).max(1.0),
488        Geometry::MultiPoint(mp) => mp.points.len() as f64,
489        Geometry::MultiLineString(mls) => mls
490            .line_strings
491            .iter()
492            .map(linestring_length)
493            .sum::<f64>()
494            .max(1.0),
495        Geometry::MultiPolygon(mp) => mp.polygons.iter().map(polygon_area).sum::<f64>().max(1.0),
496        Geometry::GeometryCollection(gc) => gc
497            .geometries
498            .iter()
499            .map(geometry_weight)
500            .sum::<f64>()
501            .max(1.0),
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    fn create_square() -> Result<Polygon> {
510        let coords = vec![
511            Coordinate::new_2d(0.0, 0.0),
512            Coordinate::new_2d(4.0, 0.0),
513            Coordinate::new_2d(4.0, 4.0),
514            Coordinate::new_2d(0.0, 4.0),
515            Coordinate::new_2d(0.0, 0.0),
516        ];
517        let exterior = LineString::new(coords).map_err(|e| AlgorithmError::Core(e))?;
518        Polygon::new(exterior, vec![]).map_err(|e| AlgorithmError::Core(e))
519    }
520
521    #[test]
522    fn test_centroid_point() {
523        let point = Point::new(3.0, 5.0);
524        let result = centroid_point(&point);
525
526        assert_eq!(result.coord.x, 3.0);
527        assert_eq!(result.coord.y, 5.0);
528    }
529
530    #[test]
531    fn test_centroid_linestring() {
532        let coords = vec![
533            Coordinate::new_2d(0.0, 0.0),
534            Coordinate::new_2d(4.0, 0.0),
535            Coordinate::new_2d(4.0, 4.0),
536        ];
537        let line = LineString::new(coords);
538        assert!(line.is_ok());
539
540        if let Ok(l) = line {
541            let result = centroid_linestring(&l);
542            assert!(result.is_ok());
543
544            if let Ok(centroid) = result {
545                // Centroid should be somewhere along the path
546                assert!(centroid.coord.x >= 0.0 && centroid.coord.x <= 4.0);
547                assert!(centroid.coord.y >= 0.0 && centroid.coord.y <= 4.0);
548            }
549        }
550    }
551
552    #[test]
553    fn test_centroid_polygon_square() {
554        let poly = create_square();
555        assert!(poly.is_ok());
556
557        if let Ok(p) = poly {
558            let result = centroid_polygon(&p);
559            assert!(result.is_ok());
560
561            if let Ok(centroid) = result {
562                // Square centroid should be at (2, 2)
563                assert!((centroid.coord.x - 2.0).abs() < 1e-10);
564                assert!((centroid.coord.y - 2.0).abs() < 1e-10);
565            }
566        }
567    }
568
569    #[test]
570    fn test_centroid_polygon_with_hole() {
571        let exterior_coords = vec![
572            Coordinate::new_2d(0.0, 0.0),
573            Coordinate::new_2d(10.0, 0.0),
574            Coordinate::new_2d(10.0, 10.0),
575            Coordinate::new_2d(0.0, 10.0),
576            Coordinate::new_2d(0.0, 0.0),
577        ];
578        let hole_coords = vec![
579            Coordinate::new_2d(2.0, 2.0),
580            Coordinate::new_2d(8.0, 2.0),
581            Coordinate::new_2d(8.0, 8.0),
582            Coordinate::new_2d(2.0, 8.0),
583            Coordinate::new_2d(2.0, 2.0),
584        ];
585
586        let exterior = LineString::new(exterior_coords);
587        let hole = LineString::new(hole_coords);
588
589        assert!(exterior.is_ok() && hole.is_ok());
590
591        if let (Ok(ext), Ok(h)) = (exterior, hole) {
592            let poly = Polygon::new(ext, vec![h]);
593            assert!(poly.is_ok());
594
595            if let Ok(p) = poly {
596                let result = centroid_polygon(&p);
597                assert!(result.is_ok());
598
599                if let Ok(centroid) = result {
600                    // Centroid should still be near (5, 5) but exact value depends on hole size
601                    assert!(centroid.coord.x >= 0.0 && centroid.coord.x <= 10.0);
602                    assert!(centroid.coord.y >= 0.0 && centroid.coord.y <= 10.0);
603                }
604            }
605        }
606    }
607
608    #[test]
609    fn test_centroid_multipoint() {
610        let points = vec![
611            Point::new(0.0, 0.0),
612            Point::new(4.0, 0.0),
613            Point::new(4.0, 4.0),
614            Point::new(0.0, 4.0),
615        ];
616        let mp = MultiPoint::new(points);
617
618        let result = centroid_multipoint(&mp);
619        assert!(result.is_ok());
620
621        if let Ok(centroid) = result {
622            // Average of 4 corners of a square
623            assert!((centroid.coord.x - 2.0).abs() < 1e-10);
624            assert!((centroid.coord.y - 2.0).abs() < 1e-10);
625        }
626    }
627
628    #[test]
629    fn test_centroid_empty_multipoint() {
630        let mp = MultiPoint::empty();
631        let result = centroid_multipoint(&mp);
632        assert!(result.is_err());
633    }
634
635    #[test]
636    fn test_linestring_length() {
637        let coords = vec![
638            Coordinate::new_2d(0.0, 0.0),
639            Coordinate::new_2d(3.0, 0.0),
640            Coordinate::new_2d(3.0, 4.0),
641        ];
642        let line = LineString::new(coords);
643        assert!(line.is_ok());
644
645        if let Ok(l) = line {
646            let length = linestring_length(&l);
647            assert!((length - 7.0).abs() < 1e-10); // 3 + 4 = 7
648        }
649    }
650
651    #[test]
652    fn test_polygon_area() {
653        let poly = create_square();
654        assert!(poly.is_ok());
655
656        if let Ok(p) = poly {
657            let area = polygon_area(&p);
658            assert!((area - 16.0).abs() < 1e-10); // 4x4 = 16
659        }
660    }
661
662    #[test]
663    fn test_ring_centroid() {
664        let coords = vec![
665            Coordinate::new_2d(0.0, 0.0),
666            Coordinate::new_2d(4.0, 0.0),
667            Coordinate::new_2d(4.0, 4.0),
668            Coordinate::new_2d(0.0, 4.0),
669            Coordinate::new_2d(0.0, 0.0),
670        ];
671
672        let result = ring_centroid(&coords);
673        assert!(result.is_ok());
674
675        if let Ok((area, cx, cy)) = result {
676            assert!((area - 16.0).abs() < 1e-10);
677            assert!((cx - 2.0).abs() < 1e-10);
678            assert!((cy - 2.0).abs() < 1e-10);
679        }
680    }
681}