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