Skip to main content

geometry_strategy/
centroid.rs

1//! `CentroidStrategy<G>` — geometric centre of a geometry.
2//!
3//! Mirrors the per-CS centroid-strategy concept from
4//! `boost/geometry/strategies/centroid/services.hpp` plus the Cartesian
5//! implementations in `boost/geometry/strategies/cartesian/centroid_*.hpp`
6//! and the per-kind dispatch in
7//! `boost/geometry/algorithms/centroid.hpp`. Per-kind Cartesian formulas:
8//!
9//! * `Segment`, `Box`          → midpoint of endpoints / corners
10//! * `Linestring`              → length-weighted midpoint of segments
11//! * `Ring` (closed) / `Polygon` → area-weighted Bashein–Detmer formula
12//! * `MultiPoint`              → arithmetic mean of points
13//!
14//! Each per-kind impl lives behind a different strategy unit-struct so
15//! coherence stays disjoint — the same distinct-struct-per-kind trick as
16//! `area` (see `strategies/cartesian/area.hpp` and the module docs of
17//! [`crate::area`]). Rust cannot prove a single type is not both a
18//! `Ring` and a `Polygon`, so a single strategy carrying overlapping
19//! `impl CentroidStrategy<G>` blocks keyed off the open traits would be
20//! rejected (E0119); the sibling unit-structs below each carry a single
21//! concept-bounded impl (`impl<G: Ring> … for CartesianRingCentroid`, …)
22//! — distinct `Self`, so no overlap. The
23//! [`CentroidStrategyForKind`] picker then routes `G::Kind` (the tag
24//! [`Geometry::Kind`] already carries) to the right struct, disjoint on
25//! the tag. This opens every kind to any concept-adapted foreign type,
26//! not just the `geometry-model` structs.
27#![allow(
28    clippy::similar_names,
29    reason = "The centroid accumulators `sum_x`/`sum_y` are the natural, domain-standard names for the per-axis running sums."
30)]
31
32use geometry_coords::CoordinateScalar;
33use geometry_cs::{CartesianFamily, CoordinateSystem};
34use geometry_tag::{
35    BoxTag, LinestringTag, MultiPointTag, MultiPolygonTag, PolygonTag, RingTag, SameAs, SegmentTag,
36};
37use geometry_trait::{
38    Box as BoxTrait, Geometry, Linestring as LinestringTrait, MultiPoint as MultiPointTrait,
39    MultiPolygon as MultiPolygonTrait, Point as PointTrait, PointMut, Polygon as PolygonTrait,
40    Ring as RingTrait, Segment as SegmentTrait, box_max, box_min, segment_end, segment_start,
41};
42
43use crate::area::{AreaStrategy, ShoelaceArea};
44use crate::cartesian::Pythagoras;
45use crate::distance::DistanceStrategy;
46
47/// A strategy for computing the centroid of `G`.
48///
49/// Mirrors the per-CS centroid-strategy concept from
50/// `boost/geometry/strategies/centroid/services.hpp`. The Boost concept
51/// exposes a stateful `apply(p1, p2, state)` accumulator plus a
52/// `result(state)` reduction (see
53/// `strategies/cartesian/centroid_bashein_detmer.hpp:173-231`); the Rust
54/// analogue collapses the two phases into a single method
55/// [`CentroidStrategy::centroid`] keyed on the geometry type.
56pub trait CentroidStrategy<G: Geometry> {
57    /// The output point type. Almost always `G::Point` — Boost picks the
58    /// input point type by default
59    /// (`strategies/default_centroid_result.hpp`).
60    type Output: PointMut + Default;
61
62    /// Compute the centroid of `g`.
63    fn centroid(&self, g: &G) -> Self::Output;
64}
65
66/// Cartesian centroid for a [`geometry_trait::Ring`] — the Bashein–Detmer formula
67/// (signed-area-weighted vertex pairs).
68///
69/// Mirrors `boost::geometry::strategy::centroid::bashein_detmer` from
70/// `strategies/cartesian/centroid_bashein_detmer.hpp:173-231`, reached
71/// through the `areal_tag` arm of
72/// `boost/geometry/algorithms/centroid.hpp`.
73#[derive(Debug, Default, Clone, Copy)]
74pub struct CartesianRingCentroid;
75
76/// Cartesian centroid for a [`geometry_trait::Polygon`] — the [`CartesianRingCentroid`]
77/// formula applied to every ring (exterior plus interiors), combined by
78/// signed area.
79///
80/// Mirrors the polygon arm of
81/// `boost/geometry/algorithms/centroid.hpp`: each interior ring's
82/// (oppositely-wound, hence oppositely-signed) area-weighted centroid is
83/// folded into the running sum, so a plain area-weighted combine already
84/// performs the hole correction.
85#[derive(Debug, Default, Clone, Copy)]
86pub struct CartesianPolygonCentroid;
87
88/// Cartesian centroid for a [`geometry_trait::MultiPolygon`] — one
89/// Bashein–Detmer accumulator over every ring of every member.
90///
91/// Mirrors the multi-polygon arm of
92/// `boost/geometry/algorithms/centroid.hpp`.
93#[derive(Debug, Default, Clone, Copy)]
94pub struct CartesianMultiPolygonCentroid;
95
96/// Cartesian centroid for a [`geometry_trait::Linestring`] — length-weighted midpoint of
97/// each segment, summed and divided by total length.
98///
99/// Mirrors the `linear_tag` arm of
100/// `boost/geometry/algorithms/centroid.hpp` together with
101/// `strategies/cartesian/centroid_average.hpp`, which averages segment
102/// midpoints weighted by segment length.
103#[derive(Debug, Default, Clone, Copy)]
104pub struct CartesianLinestringCentroid;
105
106/// Cartesian centroid for a [`geometry_trait::Segment`] — `(start + end) / 2`.
107///
108/// Mirrors the `segment_tag` arm of
109/// `boost/geometry/algorithms/centroid.hpp`, which returns the segment
110/// midpoint.
111#[derive(Debug, Default, Clone, Copy)]
112pub struct CartesianSegmentCentroid;
113
114/// Cartesian centroid for a [`geometry_trait::Box`] — corner midpoint per dimension.
115///
116/// Mirrors the `box_tag` arm of
117/// `boost/geometry/algorithms/centroid.hpp`
118/// (`detail::centroid::centroid_box`), which returns the midpoint of the
119/// min / max corners.
120#[derive(Debug, Default, Clone, Copy)]
121pub struct CartesianBoxCentroid;
122
123/// Cartesian centroid for a [`geometry_trait::MultiPoint`] — arithmetic mean of the
124/// member points.
125///
126/// Mirrors the `pointlike_tag` arm of
127/// `boost/geometry/algorithms/centroid.hpp` together with
128/// `strategies/cartesian/centroid_average.hpp`.
129#[derive(Debug, Default, Clone, Copy)]
130pub struct CartesianMultiPointCentroid;
131
132// ---- helpers ---------------------------------------------------------
133
134/// Build a 2-D point from its two coordinates via [`Default`] +
135/// `set::<0>` / `set::<1>`. Shared by the areal (Bashein–Detmer) impls,
136/// which are inherently 2-D — the C++ strategy reads only `get<0>` /
137/// `get<1>` (`centroid_bashein_detmer.hpp:191-199`).
138#[inline]
139fn point_2d<P>(x: P::Scalar, y: P::Scalar) -> P
140where
141    P: PointTrait + PointMut + Default,
142{
143    let mut p = P::default();
144    p.set::<0>(x);
145    p.set::<1>(y);
146    p
147}
148
149/// The scalar `2` (`ONE + ONE`) for the argument scalar type.
150#[inline]
151fn two<T: CoordinateScalar>() -> T {
152    T::ONE + T::ONE
153}
154
155/// The scalar `3` for the argument scalar type — the `3 * sum_a2 = 6A`
156/// divisor of `centroid_bashein_detmer.hpp:211-212`.
157#[inline]
158fn three<T: CoordinateScalar>() -> T {
159    T::ONE + T::ONE + T::ONE
160}
161
162/// The Bashein–Detmer accumulator triple `(sum_a2, sum_x, sum_y)`, all in
163/// the ring's scalar type.
164type BasheinDetmerSums<R> = (
165    <<R as Geometry>::Point as PointTrait>::Scalar,
166    <<R as Geometry>::Point as PointTrait>::Scalar,
167    <<R as Geometry>::Point as PointTrait>::Scalar,
168);
169
170/// Sum the Bashein–Detmer accumulators `(sum_a2, sum_x, sum_y)` over the
171/// consecutive vertex pairs of `r`. Mirrors the per-segment `apply` at
172/// `centroid_bashein_detmer.hpp:191-199`:
173///
174/// ```text
175/// ai      = x1 * y2 - x2 * y1
176/// sum_a2 += ai
177/// sum_x  += ai * (x1 + x2)
178/// sum_y  += ai * (y1 + y2)
179/// ```
180///
181/// For an open ring the implicit `last -> first` closing pair is added
182/// explicitly, mirroring the way [`crate::area`] closes an open ring.
183fn bashein_detmer_sums<R>(r: &R) -> BasheinDetmerSums<R>
184where
185    R: RingTrait,
186    R::Point: PointTrait,
187{
188    let zero = <R::Point as PointTrait>::Scalar::ZERO;
189    let mut sum_a2 = zero;
190    let mut sum_x = zero;
191    let mut sum_y = zero;
192
193    let mut acc = |a: &R::Point, b: &R::Point| {
194        let x1 = a.get::<0>();
195        let y1 = a.get::<1>();
196        let x2 = b.get::<0>();
197        let y2 = b.get::<1>();
198        let ai = x1 * y2 - x2 * y1;
199        sum_a2 = sum_a2 + ai;
200        sum_x = sum_x + ai * (x1 + x2);
201        sum_y = sum_y + ai * (y1 + y2);
202    };
203
204    let it = r.points();
205    let next = it.clone().skip(1);
206    for (a, b) in it.zip(next) {
207        acc(a, b);
208    }
209    if matches!(r.closure(), geometry_trait::Closure::Open) {
210        let mut points = r.points();
211        if let Some(first) = points.next() {
212            let last = points.last().unwrap_or(first);
213            acc(last, first);
214        }
215    }
216
217    (sum_a2, sum_x, sum_y)
218}
219
220// ---- Ring ------------------------------------------------------------
221//
222// Mirrors `strategy::centroid::bashein_detmer::result` at
223// `centroid_bashein_detmer.hpp:202-231`: `Cx = sum_x / (3 * sum_a2)`,
224// `Cy = sum_y / (3 * sum_a2)`. When `sum_a2 == 0` (a degenerate, zero-
225// area ring) Boost's `result` returns `false` and the higher-level
226// `centroid_polygon` falls back to the first ring vertex
227// (`test/algorithms/centroid.cpp:50-57`); we mirror that fallback here.
228
229impl<G> CentroidStrategy<G> for CartesianRingCentroid
230where
231    G: RingTrait,
232    G::Point: PointTrait + PointMut + Default + Copy,
233    <<G::Point as PointTrait>::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
234    ShoelaceArea: AreaStrategy<G, Out = <G::Point as PointTrait>::Scalar>,
235{
236    type Output = G::Point;
237
238    fn centroid(&self, r: &G) -> G::Point {
239        let (sum_a2, sum_x, sum_y) = bashein_detmer_sums(r);
240        let zero = <G::Point as PointTrait>::Scalar::ZERO;
241        if sum_a2 == zero {
242            // Degenerate ring: fall back to the first vertex
243            // (`centroid.cpp:50-57`). An empty ring yields the origin
244            // (Default), matching a zero-init result point.
245            return r.points().next().copied().unwrap_or_default();
246        }
247        let a3 = three::<<G::Point as PointTrait>::Scalar>() * sum_a2;
248        point_2d::<G::Point>(sum_x / a3, sum_y / a3)
249    }
250}
251
252// ---- Polygon ---------------------------------------------------------
253//
254// Mirrors the polygon arm of `algorithms/centroid.hpp`. Each ring
255// contributes `signed_area_k * centroid_k`; the interior rings arrive
256// with the opposite sign under `ShoelaceArea` (Boost's signed-area
257// convention winds holes opposite the exterior), so a plain sum performs
258// the hole subtraction. The result is `sum_c / sum_area`, degenerating
259// to the exterior ring's first vertex when the total signed area is 0.
260
261impl<G> CentroidStrategy<G> for CartesianPolygonCentroid
262where
263    G: PolygonTrait,
264    G::Point: PointTrait + PointMut + Default + Copy,
265    <<G::Point as PointTrait>::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
266    ShoelaceArea: AreaStrategy<G::Ring, Out = <G::Point as PointTrait>::Scalar>,
267    CartesianRingCentroid: CentroidStrategy<G::Ring, Output = G::Point>,
268{
269    type Output = G::Point;
270
271    fn centroid(&self, pg: &G) -> G::Point {
272        let zero = <G::Point as PointTrait>::Scalar::ZERO;
273        let mut sum_a2 = zero;
274        let mut sum_x = zero;
275        let mut sum_y = zero;
276
277        let mut fold_ring = |ring: &G::Ring| {
278            let (a2, x, y) = bashein_detmer_sums(ring);
279            sum_a2 = sum_a2 + a2;
280            sum_x = sum_x + x;
281            sum_y = sum_y + y;
282        };
283
284        fold_ring(pg.exterior());
285        for inner in pg.interiors() {
286            fold_ring(inner);
287        }
288
289        if sum_a2 == zero {
290            return pg.exterior().points().next().copied().unwrap_or_default();
291        }
292        let a3 = three::<<G::Point as PointTrait>::Scalar>() * sum_a2;
293        point_2d::<G::Point>(sum_x / a3, sum_y / a3)
294    }
295}
296
297// ---- MultiPolygon ----------------------------------------------------
298//
299// Mirrors the multi-polygon arm of `algorithms/centroid.hpp`, which runs
300// one `centroid_multi` state over every ring of every member and divides
301// once. Same reason the polygon arm accumulates rather than combining
302// per-part centroids: a member with zero area drops out of an
303// area-weighted combine but still contributes to the running numerator,
304// and Boost keeps that contribution.
305
306impl<G> CentroidStrategy<G> for CartesianMultiPolygonCentroid
307where
308    G: MultiPolygonTrait,
309    G::Point: PointTrait + PointMut + Default + Copy,
310    <<G::Point as PointTrait>::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
311{
312    type Output = G::Point;
313
314    fn centroid(&self, mp: &G) -> G::Point {
315        let zero = <G::Point as PointTrait>::Scalar::ZERO;
316        let mut sum_a2 = zero;
317        let mut sum_x = zero;
318        let mut sum_y = zero;
319        let mut first_point = None;
320
321        for polygon in mp.polygons() {
322            if first_point.is_none() {
323                first_point = polygon.exterior().points().next().copied();
324            }
325            for ring in core::iter::once(polygon.exterior()).chain(polygon.interiors()) {
326                let (a2, x, y) = bashein_detmer_sums(ring);
327                sum_a2 = sum_a2 + a2;
328                sum_x = sum_x + x;
329                sum_y = sum_y + y;
330            }
331        }
332
333        if sum_a2 == zero {
334            return first_point.unwrap_or_default();
335        }
336        let a3 = three::<<G::Point as PointTrait>::Scalar>() * sum_a2;
337        point_2d::<G::Point>(sum_x / a3, sum_y / a3)
338    }
339}
340
341// ---- Linestring ------------------------------------------------------
342//
343// Mirrors the linear arm of `algorithms/centroid.hpp`: each segment
344// contributes `seg_length * midpoint`, summed and divided by the total
345// length. Degenerate (total length 0) falls back to the first point
346// (`centroid.cpp:81-82`).
347
348impl<G> CentroidStrategy<G> for CartesianLinestringCentroid
349where
350    G: LinestringTrait,
351    G::Point: PointTrait + PointMut + Default + Copy,
352    <<G::Point as PointTrait>::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
353    Pythagoras: DistanceStrategy<G::Point, G::Point, Out = <G::Point as PointTrait>::Scalar>,
354{
355    type Output = G::Point;
356
357    fn centroid(&self, ls: &G) -> G::Point {
358        let zero = <G::Point as PointTrait>::Scalar::ZERO;
359        let half =
360            <G::Point as PointTrait>::Scalar::ONE / two::<<G::Point as PointTrait>::Scalar>();
361        let mut total_len = zero;
362        let mut sum_x = zero;
363        let mut sum_y = zero;
364
365        let it = ls.points();
366        let next = it.clone().skip(1);
367        for (a, b) in it.zip(next) {
368            let seg_len = Pythagoras.distance(a, b);
369            let mid_x = (a.get::<0>() + b.get::<0>()) * half;
370            let mid_y = (a.get::<1>() + b.get::<1>()) * half;
371            total_len = total_len + seg_len;
372            sum_x = sum_x + seg_len * mid_x;
373            sum_y = sum_y + seg_len * mid_y;
374        }
375
376        if total_len == zero {
377            return ls.points().next().copied().unwrap_or_default();
378        }
379        point_2d::<G::Point>(sum_x / total_len, sum_y / total_len)
380    }
381}
382
383// ---- Segment ---------------------------------------------------------
384//
385// Mirrors the segment arm of `algorithms/centroid.hpp`: the midpoint of
386// the two endpoints, per dimension.
387
388impl<G> CentroidStrategy<G> for CartesianSegmentCentroid
389where
390    G: SegmentTrait,
391    G::Point: PointTrait + PointMut + Default + Copy,
392    <<G::Point as PointTrait>::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
393{
394    type Output = G::Point;
395
396    fn centroid(&self, s: &G) -> G::Point {
397        let a = segment_start(s);
398        let b = segment_end(s);
399        midpoint(&a, &b)
400    }
401}
402
403// ---- Box -------------------------------------------------------------
404//
405// Mirrors `detail::centroid::centroid_box` in
406// `algorithms/centroid.hpp`: the midpoint of the min / max corners, per
407// dimension.
408
409impl<G> CentroidStrategy<G> for CartesianBoxCentroid
410where
411    G: BoxTrait,
412    G::Point: PointTrait + PointMut + Default + Copy,
413    <<G::Point as PointTrait>::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
414{
415    type Output = G::Point;
416
417    fn centroid(&self, b: &G) -> G::Point {
418        let lo = box_min(b);
419        let hi = box_max(b);
420        midpoint(&lo, &hi)
421    }
422}
423
424// ---- MultiPoint ------------------------------------------------------
425//
426// Mirrors the pointlike arm of `algorithms/centroid.hpp`: the arithmetic
427// mean of the member points, per dimension. Degenerate (no points) falls
428// back to the origin (a zero-init default point).
429
430impl<G> CentroidStrategy<G> for CartesianMultiPointCentroid
431where
432    G: MultiPointTrait,
433    G::ItemPoint: PointTrait + PointMut + Default + Copy,
434    <<G::ItemPoint as PointTrait>::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
435{
436    type Output = G::ItemPoint;
437
438    fn centroid(&self, mp: &G) -> G::ItemPoint {
439        let zero = <G::ItemPoint as PointTrait>::Scalar::ZERO;
440        let mut count = zero;
441        let mut sum_x = zero;
442        let mut sum_y = zero;
443        for p in mp.points() {
444            sum_x = sum_x + p.get::<0>();
445            sum_y = sum_y + p.get::<1>();
446            count = count + <G::ItemPoint as PointTrait>::Scalar::ONE;
447        }
448        if count == zero {
449            return G::ItemPoint::default();
450        }
451        point_2d::<G::ItemPoint>(sum_x / count, sum_y / count)
452    }
453}
454
455/// The per-dimension midpoint of two points — `(a + b) / 2` on
456/// dimensions `0` and `1`. Shared by the [`geometry_trait::Segment`] and [`geometry_trait::Box`] impls,
457/// which are both a two-corner midpoint. 2-D only, matching the rest of
458/// this module and the reference test coverage.
459#[inline]
460fn midpoint<P>(a: &P, b: &P) -> P
461where
462    P: PointTrait + PointMut + Default,
463{
464    let half = P::Scalar::ONE / two::<P::Scalar>();
465    let x = (a.get::<0>() + b.get::<0>()) * half;
466    let y = (a.get::<1>() + b.get::<1>()) * half;
467    point_2d::<P>(x, y)
468}
469
470/// Type-level "which centroid strategy does this geometry *kind* use".
471///
472/// One impl per [`geometry_tag`] kind tag, mapping each tag to its
473/// per-kind [`CentroidStrategy`] struct above. Keyed on the **tag**
474/// (`impl CentroidStrategyForKind for RingTag`) rather than on a concept
475/// blanket (`impl<G: Ring> … for G`, which would overlap its `Polygon`
476/// sibling — E0119) or on the concrete `geometry-model` structs (which
477/// would keep `centroid` model-bound). Distinct tags never conflict, so
478/// the picker is coherent; a concept-adapted foreign type resolves to the
479/// same struct as the equivalent model value because they share a
480/// `Kind`. The `geometry-algorithm::centroid` free function routes
481/// `G → G::Kind → S` through this trait, staying strategy-less while
482/// leaving room for the explicit-strategy `centroid_with`.
483///
484/// # Spherical / geographic centroid — DEFERRED (LA8.T3)
485///
486/// The per-kind impls above are all gated on
487/// `<…::Cs>::Family: SameAs<CartesianFamily>`, so `centroid(&g)` is a
488/// compile error for a spherical or geographic geometry — that is
489/// intentional. Boost's *area* and *azimuth* have exact, published
490/// reference values (which LA8.T1/T2/T4 reproduce), but Boost ships **no
491/// dedicated spherical / geographic centroid test values**: its
492/// `strategies/centroid/spherical.hpp` merely marks `Box` / `Segment`
493/// "not applicable" and otherwise inherits the Cartesian
494/// `centroid_average` (an arithmetic mean of lon/lat, *not* a true
495/// on-sphere centroid). The LA8.T3 stub instead sketches a different
496/// algorithm (project to 3-D unit normals, area-weight, normalise, map
497/// back) with **no reference rows to validate against**.
498///
499/// Per the task's "prefer correctness over coverage — skip + document
500/// rather than ship wrong math" directive, the non-Cartesian centroid is
501/// deferred until a validated reference exists. Callers who need it today
502/// can supply an explicit strategy through
503/// `geometry_algorithm::centroid_with`. The `DefaultLength` /
504/// `DefaultArea` / `DefaultAzimuth` family-keyed dispatch traits added in
505/// LA8 give the eventual family impl a ready-made shape to follow.
506#[doc(hidden)]
507pub trait CentroidStrategyForKind {
508    /// The per-kind [`CentroidStrategy`] struct this tag is computed with.
509    type S: Default;
510}
511
512impl CentroidStrategyForKind for RingTag {
513    type S = CartesianRingCentroid;
514}
515
516impl CentroidStrategyForKind for MultiPolygonTag {
517    type S = CartesianMultiPolygonCentroid;
518}
519
520impl CentroidStrategyForKind for PolygonTag {
521    type S = CartesianPolygonCentroid;
522}
523
524impl CentroidStrategyForKind for LinestringTag {
525    type S = CartesianLinestringCentroid;
526}
527
528impl CentroidStrategyForKind for SegmentTag {
529    type S = CartesianSegmentCentroid;
530}
531
532impl CentroidStrategyForKind for BoxTag {
533    type S = CartesianBoxCentroid;
534}
535
536impl CentroidStrategyForKind for MultiPointTag {
537    type S = CartesianMultiPointCentroid;
538}
539
540#[cfg(test)]
541mod tests {
542    //! Reference values from `geometry/test/algorithms/centroid.cpp`.
543    //! `BOOST_CHECK_CLOSE` there uses a 0.0001 % tolerance; the exact
544    //! reference doubles are reproduced with `1e-9` absolute tolerance.
545    #![allow(
546        clippy::float_cmp,
547        reason = "centroids are compared with an explicit absolute tolerance, not `==`"
548    )]
549
550    use super::{
551        CartesianBoxCentroid, CartesianLinestringCentroid, CartesianMultiPointCentroid,
552        CartesianMultiPolygonCentroid, CartesianPolygonCentroid, CartesianRingCentroid,
553        CartesianSegmentCentroid, CentroidStrategy,
554    };
555    use geometry_cs::Cartesian;
556    use geometry_model::{
557        Box, MultiPoint, MultiPolygon, Point2D, Polygon, Ring, Segment, linestring, polygon,
558    };
559    use geometry_trait::Point as _;
560
561    type Pt = Point2D<f64, Cartesian>;
562
563    fn close_pt(got: &Pt, x: f64, y: f64, tol: f64) -> bool {
564        (got.get::<0>() - x).abs() < tol && (got.get::<1>() - y).abs() < tol
565    }
566
567    // centroid.cpp:139 — ring "POLYGON((1 1, 1 2, 2 2, 2 1, 1 1))" → (1.5, 1.5)
568    #[test]
569    fn ring_centroid_unit_square_shift() {
570        let r: Ring<Pt> = Ring::from_vec(vec![
571            Pt::new(1., 1.),
572            Pt::new(1., 2.),
573            Pt::new(2., 2.),
574            Pt::new(2., 1.),
575            Pt::new(1., 1.),
576        ]);
577        let c = CartesianRingCentroid.centroid(&r);
578        assert!(close_pt(&c, 1.5, 1.5, 1e-9));
579    }
580
581    // centroid.cpp:111-114 — the Bashein/Detmer reference ring →
582    // (4.06923363095238, 1.65055803571429).
583    #[test]
584    fn ring_bashein_detmer_reference() {
585        let r: Ring<Pt> = Ring::from_vec(vec![
586            Pt::new(2., 1.3),
587            Pt::new(2.4, 1.7),
588            Pt::new(2.8, 1.8),
589            Pt::new(3.4, 1.2),
590            Pt::new(3.7, 1.6),
591            Pt::new(3.4, 2.),
592            Pt::new(4.1, 3.),
593            Pt::new(5.3, 2.6),
594            Pt::new(5.4, 1.2),
595            Pt::new(4.9, 0.8),
596            Pt::new(2.9, 0.7),
597            Pt::new(2., 1.3),
598        ]);
599        let c = CartesianRingCentroid.centroid(&r);
600        assert!(close_pt(
601            &c,
602            4.069_233_630_952_38,
603            1.650_558_035_714_29,
604            1e-9
605        ));
606    }
607
608    // centroid.cpp:46 — POLYGON((0 0,0 10,10 10,10 0,0 0)) → (5, 5)
609    #[test]
610    fn polygon_10x10_square_centroid_is_5_5() {
611        let pg: Polygon<Pt> = polygon![[(0., 0.), (0., 10.), (10., 10.), (10., 0.), (0., 0.)]];
612        let c = CartesianPolygonCentroid.centroid(&pg);
613        assert!(close_pt(&c, 5.0, 5.0, 1e-9));
614    }
615
616    // centroid.cpp:191-192 — POLYGON((0 0, 1 0, 1 1, 0 1, 0 0), ()) → (0.5, 0.5).
617    // (Unit square, plus an empty interior ring is a no-op.)
618    #[test]
619    fn polygon_unit_square_centroid_is_half_half() {
620        let pg: Polygon<Pt> = polygon![[(0., 0.), (1., 0.), (1., 1.), (0., 1.), (0., 0.)]];
621        let c = CartesianPolygonCentroid.centroid(&pg);
622        assert!(close_pt(&c, 0.5, 0.5, 1e-9));
623    }
624
625    // centroid.cpp:40-44 — the Bashein/Detmer reference polygon *with a
626    // hole*. The C++ test asserts SQL Server's constant
627    // `(4.0466264962959677, 1.6348996057331333)` with a 0.0001 %
628    // `BOOST_CHECK_CLOSE` tolerance. Boost's own Bashein/Detmer kernel
629    // (which this mirrors) produces the PostGIS / Oracle value
630    // `(4.0466265060241, 1.63489959839357)` quoted at
631    // `centroid_bashein_detmer.hpp:99` — the two agree to ~1e-8, well
632    // inside 0.0001 %. We assert the value the algorithm actually
633    // computes (PostGIS / Oracle) so the tolerance can stay tight.
634    #[test]
635    fn polygon_with_hole_reference() {
636        let pg: Polygon<Pt> = polygon![
637            [
638                (2., 1.3),
639                (2.4, 1.7),
640                (2.8, 1.8),
641                (3.4, 1.2),
642                (3.7, 1.6),
643                (3.4, 2.),
644                (4.1, 3.),
645                (5.3, 2.6),
646                (5.4, 1.2),
647                (4.9, 0.8),
648                (2.9, 0.7),
649                (2., 1.3)
650            ],
651            [(4., 2.), (4.2, 1.4), (4.8, 1.9), (4.4, 2.2), (4., 2.)]
652        ];
653        let c = CartesianPolygonCentroid.centroid(&pg);
654        assert!(close_pt(
655            &c,
656            4.046_626_506_024_1,
657            1.634_899_598_393_57,
658            1e-9
659        ));
660    }
661
662    // centroid.cpp:50 — invalid, self-intersecting (area = 0) polygon →
663    // fall back to first vertex (1, 1).
664    #[test]
665    fn degenerate_zero_area_polygon_returns_first_vertex() {
666        let pg: Polygon<Pt> = polygon![[
667            (1., 1.),
668            (4., -2.),
669            (4., 2.),
670            (10., 0.),
671            (1., 0.),
672            (10., 1.),
673            (1., 1.)
674        ]];
675        let c = CartesianPolygonCentroid.centroid(&pg);
676        assert!(close_pt(&c, 1.0, 1.0, 1e-9));
677    }
678
679    // centroid.cpp:73 — LINESTRING(1 1, 2 2, 3 3) → (2, 2)
680    #[test]
681    fn linestring_centroid_diagonal() {
682        let ls = linestring![(1., 1.), (2., 2.), (3., 3.)];
683        let c = CartesianLinestringCentroid.centroid(&ls);
684        assert!(close_pt(&c, 2.0, 2.0, 1e-9));
685    }
686
687    // centroid.cpp:74 — LINESTRING(0 0,0 4, 4 4) → (1, 3)
688    #[test]
689    fn linestring_centroid_bent() {
690        let ls = linestring![(0., 0.), (0., 4.), (4., 4.)];
691        let c = CartesianLinestringCentroid.centroid(&ls);
692        assert!(close_pt(&c, 1.0, 3.0, 1e-9));
693    }
694
695    // centroid.cpp:81 — degenerate (length 0) linestring → first point.
696    #[test]
697    fn linestring_degenerate_returns_first_point() {
698        let ls = linestring![(1., 1.), (1., 1.)];
699        let c = CartesianLinestringCentroid.centroid(&ls);
700        assert!(close_pt(&c, 1.0, 1.0, 1e-9));
701    }
702
703    // centroid.cpp:109 — segment (1 1) → (3 3) → midpoint (2, 2)
704    #[test]
705    fn segment_midpoint() {
706        let s = Segment::new(Pt::new(1., 1.), Pt::new(3., 3.));
707        let c = CartesianSegmentCentroid.centroid(&s);
708        assert!(close_pt(&c, 2.0, 2.0, 1e-12));
709    }
710
711    // centroid.cpp:131 — box "POLYGON((1 2,3 4))" → (2, 3)
712    #[test]
713    fn box_centroid() {
714        let b: Box<Pt> = Box::from_corners(Pt::new(1., 2.), Pt::new(3., 4.));
715        let c = CartesianBoxCentroid.centroid(&b);
716        assert!(close_pt(&c, 2.0, 3.0, 1e-12));
717    }
718
719    // MultiPoint {(0,0),(2,0),(0,2)} → arithmetic mean (2/3, 2/3).
720    #[test]
721    fn multipoint_mean() {
722        let mp: MultiPoint<Pt> =
723            MultiPoint::from_vec(vec![Pt::new(0., 0.), Pt::new(2., 0.), Pt::new(0., 2.)]);
724        let c = CartesianMultiPointCentroid.centroid(&mp);
725        assert!(close_pt(&c, 2.0 / 3.0, 2.0 / 3.0, 1e-9));
726    }
727
728    /// A part with zero area still contributes to the running numerator.
729    ///
730    /// Combining per-part centroids weighted by area drops it — its weight is
731    /// zero — and lands somewhere else. Boost 1.83 on a clockwise
732    /// `model::polygon` / `model::multi_polygon`:
733    ///
734    /// ```text
735    /// bowtie exterior + hole  -> (10.6667, 11)  area=-4
736    /// zero-area MP            -> (0, 0)         area=0
737    /// mixed MP                -> (11.3333, 11)  area=4
738    /// ```
739    #[test]
740    fn a_zero_area_part_still_moves_the_centroid() {
741        // Exterior is a bow-tie: zero area, non-zero numerator.
742        let bowtie_with_hole: Polygon<Pt> = polygon![
743            [(0.0, 0.0), (2.0, 2.0), (2.0, 0.0), (0.0, 2.0), (0.0, 0.0)],
744            [
745                (10.0, 10.0),
746                (12.0, 10.0),
747                (12.0, 12.0),
748                (10.0, 12.0),
749                (10.0, 10.0)
750            ]
751        ];
752        let c = CartesianPolygonCentroid.centroid(&bowtie_with_hole);
753        assert!(close_pt(&c, 32.0 / 3.0, 11.0, 1e-9), "{c:?}");
754
755        let bowtie: Polygon<Pt> =
756            polygon![[(0.0, 0.0), (2.0, 2.0), (2.0, 0.0), (0.0, 2.0), (0.0, 0.0)]];
757        let other_bowtie: Polygon<Pt> = polygon![[
758            (10.0, 10.0),
759            (12.0, 12.0),
760            (12.0, 10.0),
761            (10.0, 12.0),
762            (10.0, 10.0)
763        ]];
764        let square: Polygon<Pt> = polygon![[
765            (10.0, 10.0),
766            (10.0, 12.0),
767            (12.0, 12.0),
768            (12.0, 10.0),
769            (10.0, 10.0)
770        ]];
771
772        // Every member degenerate: the first vertex of the first member.
773        let all_degenerate = MultiPolygon(vec![bowtie.clone(), other_bowtie]);
774        let c = CartesianMultiPolygonCentroid.centroid(&all_degenerate);
775        assert!(close_pt(&c, 0.0, 0.0, 1e-9), "{c:?}");
776
777        // One degenerate member beside a real one: it still pulls the result.
778        let mixed = MultiPolygon(vec![bowtie, square]);
779        let c = CartesianMultiPolygonCentroid.centroid(&mixed);
780        assert!(close_pt(&c, 34.0 / 3.0, 11.0, 1e-9), "{c:?}");
781    }
782}