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