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