Skip to main content

parquet_geospatial/
bounding.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::collections::HashSet;
19
20use arrow_schema::ArrowError;
21use geo_traits::{
22    CoordTrait, Dimensions, GeometryCollectionTrait, GeometryTrait, GeometryType, LineStringTrait,
23    MultiLineStringTrait, MultiPointTrait, MultiPolygonTrait, PointTrait, PolygonTrait,
24};
25use wkb::reader::Wkb;
26
27use crate::interval::{Interval, IntervalTrait, WraparoundInterval};
28
29/// Geometry bounder
30///
31/// Utility to accumulate statistics for geometries as they are written.
32/// This bounder is designed to output statistics accumulated according
33/// to the Parquet specification such that the output can be written to
34/// Parquet statistics with minimal modification.
35///
36/// See the [IntervalTrait] for an in-depth discussion of wraparound bounding
37/// (which adds some complexity to this implementation).
38#[derive(Debug)]
39pub struct GeometryBounder {
40    /// Union of all contiguous x intervals to the left of the wraparound midpoint
41    x_left: Interval,
42    /// Union of all contiguous x intervals that intersect the wraparound midpoint
43    x_mid: Interval,
44    /// Union of all contiguous x intervals to the right of the wraparound midpoint
45    x_right: Interval,
46    /// Union of all y intervals
47    y: Interval,
48    /// Union of all z intervals
49    z: Interval,
50    /// Union of all m intervals
51    m: Interval,
52    /// Unique geometry type codes encountered by the bounder
53    ///
54    /// The integer codes are identical to the ISO WKB geometry type codes and
55    /// are documented as part of the Parquet specification:
56    /// <https://github.com/apache/parquet-format/blob/master/Geospatial.md#geospatial-types>
57    geometry_types: HashSet<i32>,
58    wraparound_hint: Interval,
59}
60
61impl GeometryBounder {
62    /// Create a new, empty bounder that represents empty input
63    pub fn empty() -> Self {
64        Self {
65            x_left: Interval::empty(),
66            x_mid: Interval::empty(),
67            x_right: Interval::empty(),
68            y: Interval::empty(),
69            z: Interval::empty(),
70            m: Interval::empty(),
71            geometry_types: HashSet::<i32>::default(),
72            wraparound_hint: Interval::empty(),
73        }
74    }
75
76    /// Set the hint to use for generation of potential wraparound xmin/xmax output
77    ///
78    /// Usually this value should be set to (-180, 180), as wraparound is primarily
79    /// targeted at lon/lat coordinate systems where collections of features with
80    /// components at the very far left and very far right of the coordinate system
81    /// are actually very close to each other.
82    ///
83    /// It is safe to set this value even when the actual coordinate system of the
84    /// input is unknown: if the input has coordinate values that are outside the
85    /// range of the wraparound hint, wraparound xmin/xmax values will not be
86    /// generated. If the input has coordinate values that are well inside of the
87    /// range of the wraparound hint, the wraparound xmin/xmax value will be
88    /// substantially wider than the non-wraparound version and will not be returned.
89    pub fn with_wraparound_hint(self, wraparound_hint: impl Into<Interval>) -> Self {
90        Self {
91            wraparound_hint: wraparound_hint.into(),
92            ..self
93        }
94    }
95
96    /// Calculate the final xmin and xmax for geometries encountered by this bounder
97    ///
98    /// The interval returned may wraparound if a hint was set and the input
99    /// encountered by this bounder were exclusively at the far left and far right
100    /// of the input range. See [IntervalTrait] for an in-depth description of
101    /// wraparound intervals.
102    pub fn x(&self) -> WraparoundInterval {
103        let out_all = Interval::empty()
104            .merge_interval(&self.x_left)
105            .merge_interval(&self.x_mid)
106            .merge_interval(&self.x_right);
107
108        // Check if this even makes sense: if anything is covering the midpoint
109        // of the wraparound hint or the bounds don't make sense for the provided
110        // wraparound hint, just return the Cartesian bounds.
111        if !self.x_mid.is_empty() || !self.wraparound_hint.contains_interval(&out_all) {
112            return out_all.into();
113        }
114
115        // Check if our wraparound bounds are any better than our Cartesian bounds
116        // If the Cartesian bounds are tighter, return them.
117        let out_width = (self.x_left.hi() - self.wraparound_hint.lo())
118            + (self.wraparound_hint.hi() - self.x_right.lo());
119        if out_all.width() < out_width {
120            return out_all.into();
121        }
122
123        // Wraparound!
124        WraparoundInterval::new(self.x_right.lo(), self.x_left.hi())
125    }
126
127    /// Calculate the final ymin and ymax for geometries encountered by this bounder
128    pub fn y(&self) -> Interval {
129        self.y
130    }
131
132    /// Calculate the final zmin and zmax for geometries encountered by this bounder
133    pub fn z(&self) -> Interval {
134        self.z
135    }
136
137    /// Calculate the final mmin and mmax values for geometries encountered by this bounder
138    pub fn m(&self) -> Interval {
139        self.m
140    }
141
142    /// Calculate the final geometry type set
143    ///
144    /// Returns a copy of the unique geometry type/dimension combinations encountered
145    /// by this bounder. These identifiers are ISO WKB identifiers (e.g., 1001
146    /// for PointZ). The output is always returned sorted.
147    pub fn geometry_types(&self) -> Vec<i32> {
148        let mut out = self.geometry_types.iter().copied().collect::<Vec<_>>();
149        out.sort_unstable();
150        out
151    }
152
153    /// Update this bounder with one WKB-encoded geometry
154    ///
155    /// Parses and accumulates the bounds of one WKB-encoded geometry. This function
156    /// will error for invalid WKB input; however, clients may wish to ignore such
157    /// an error for the purposes of writing statistics.
158    pub fn update_wkb(&mut self, wkb: &[u8]) -> Result<(), ArrowError> {
159        let wkb = Wkb::try_new(wkb).map_err(|e| ArrowError::ExternalError(Box::new(e)))?;
160        self.update_geometry(&wkb)?;
161        Ok(())
162    }
163
164    fn update_geometry(&mut self, geom: &impl GeometryTrait<T = f64>) -> Result<(), ArrowError> {
165        let geometry_type = geometry_type(geom)?;
166        self.geometry_types.insert(geometry_type);
167
168        visit_intervals(geom, 'x', &mut |x| self.update_x(&x))?;
169        visit_intervals(geom, 'y', &mut |y| self.y.update_interval(&y))?;
170        visit_intervals(geom, 'z', &mut |z| self.z.update_interval(&z))?;
171        visit_intervals(geom, 'm', &mut |m| self.m.update_interval(&m))?;
172
173        Ok(())
174    }
175
176    fn update_x(&mut self, x: &Interval) {
177        if x.hi() < self.wraparound_hint.mid() {
178            // If the x interval is completely to the left of the midpoint, merge it
179            // with x_left
180            self.x_left.update_interval(x);
181        } else if x.lo() > self.wraparound_hint.mid() {
182            // If the x interval is completely to the right of the midpoint, merge it
183            // with x_right
184            self.x_right.update_interval(x);
185        } else {
186            // Otherwise, merge it with x_mid
187            self.x_mid.update_interval(x);
188        }
189    }
190}
191
192/// Visit contiguous intervals for a given dimension within a [GeometryTrait]
193///
194/// Here, contiguous intervals refers to intervals that must not be separated
195/// by wraparound bounding. Point components of a geometry are visited as
196/// degenerate intervals of a single value; linestring or polygon ring components
197/// are visited as single intervals.
198fn visit_intervals(
199    geom: &impl GeometryTrait<T = f64>,
200    dimension: char,
201    func: &mut impl FnMut(Interval),
202) -> Result<(), ArrowError> {
203    let Some(n) = dimension_index(geom.dim(), dimension) else {
204        return Ok(());
205    };
206
207    match geom.as_type() {
208        GeometryType::Point(pt) => {
209            if let Some(coord) = PointTrait::coord(pt) {
210                visit_point(coord, n, func);
211            }
212        }
213        GeometryType::LineString(ls) => {
214            visit_sequence(ls.coords(), n, func);
215        }
216        GeometryType::Polygon(pl) => {
217            if let Some(exterior) = pl.exterior() {
218                visit_sequence(exterior.coords(), n, func);
219            }
220
221            for interior in pl.interiors() {
222                visit_sequence(interior.coords(), n, func);
223            }
224        }
225        GeometryType::MultiPoint(multi_pt) => {
226            visit_collection(multi_pt.points(), dimension, func)?;
227        }
228        GeometryType::MultiLineString(multi_ls) => {
229            visit_collection(multi_ls.line_strings(), dimension, func)?;
230        }
231        GeometryType::MultiPolygon(multi_pl) => {
232            visit_collection(multi_pl.polygons(), dimension, func)?;
233        }
234        GeometryType::GeometryCollection(collection) => {
235            visit_collection(collection.geometries(), dimension, func)?;
236        }
237        _ => {
238            return Err(ArrowError::InvalidArgumentError(
239                "GeometryType not supported for dimension bounds".to_string(),
240            ));
241        }
242    }
243
244    Ok(())
245}
246
247/// Visit a point
248///
249/// Points can be separated by wraparound bounding even if they occur within
250/// the same feature, so we visit them as individual degenerate intervals.
251fn visit_point(coord: impl CoordTrait<T = f64>, n: usize, func: &mut impl FnMut(Interval)) {
252    let val = unsafe { coord.nth_unchecked(n) };
253    func((val, val).into());
254}
255
256/// Visit contiguous sequences
257///
258/// Sequences (e.g., linestrings or polygon rings) must always be considered
259/// together (i.e., are never separated by wraparound bounding).
260fn visit_sequence(
261    coords: impl IntoIterator<Item = impl CoordTrait<T = f64>>,
262    n: usize,
263    func: &mut impl FnMut(Interval),
264) {
265    let mut interval = Interval::empty();
266    for coord in coords {
267        interval.update_value(unsafe { coord.nth_unchecked(n) });
268    }
269
270    func(interval);
271}
272
273/// Visit intervals in a collection of geometries
274fn visit_collection(
275    collection: impl IntoIterator<Item = impl GeometryTrait<T = f64>>,
276    target: char,
277    func: &mut impl FnMut(Interval),
278) -> Result<(), ArrowError> {
279    for geom in collection {
280        visit_intervals(&geom, target, func)?;
281    }
282
283    Ok(())
284}
285
286/// Extract the geometry type code encountered by the bounder
287///
288/// The integer code is a ISO WKB geometry type codes is documented as part
289/// of the Parquet specification:
290/// <https://github.com/apache/parquet-format/blob/master/Geospatial.md#geospatial-types>
291///
292/// This can also be derived from bytes 2-5 (possibly endian-swapped according to byte 1)
293/// of the input WKB buffer but is slightly clearer recomputed.
294fn geometry_type(geom: &impl GeometryTrait<T = f64>) -> Result<i32, ArrowError> {
295    let dimension_type = match geom.dim() {
296        Dimensions::Xy => 0,
297        Dimensions::Xyz => 1000,
298        Dimensions::Xym => 2000,
299        Dimensions::Xyzm => 3000,
300        Dimensions::Unknown(_) => {
301            return Err(ArrowError::InvalidArgumentError(
302                "Unsupported dimensions".to_string(),
303            ));
304        }
305    };
306
307    let geometry_type = match geom.as_type() {
308        GeometryType::Point(_) => 1,
309        GeometryType::LineString(_) => 2,
310        GeometryType::Polygon(_) => 3,
311        GeometryType::MultiPoint(_) => 4,
312        GeometryType::MultiLineString(_) => 5,
313        GeometryType::MultiPolygon(_) => 6,
314        GeometryType::GeometryCollection(_) => 7,
315        _ => {
316            return Err(ArrowError::InvalidArgumentError(
317                "GeometryType not supported for dimension bounds".to_string(),
318            ));
319        }
320    };
321
322    Ok(dimension_type + geometry_type)
323}
324
325fn dimension_index(dim: Dimensions, target: char) -> Option<usize> {
326    match target {
327        'x' => return Some(0),
328        'y' => return Some(1),
329        _ => {}
330    }
331
332    match (dim, target) {
333        (Dimensions::Xyz, 'z') => Some(2),
334        (Dimensions::Xym, 'm') => Some(2),
335        (Dimensions::Xyzm, 'z') => Some(2),
336        (Dimensions::Xyzm, 'm') => Some(3),
337        (_, _) => None,
338    }
339}
340
341#[cfg(test)]
342mod test {
343
344    use std::str::FromStr;
345
346    use wkt::Wkt;
347
348    use super::*;
349
350    fn wkt_bounds(
351        wkt_values: impl IntoIterator<Item = impl AsRef<str>>,
352    ) -> Result<GeometryBounder, ArrowError> {
353        wkt_bounds_with_wraparound(wkt_values, Interval::empty())
354    }
355
356    fn wkt_bounds_with_wraparound(
357        wkt_values: impl IntoIterator<Item = impl AsRef<str>>,
358        wraparound: impl Into<Interval>,
359    ) -> Result<GeometryBounder, ArrowError> {
360        let mut bounder = GeometryBounder::empty().with_wraparound_hint(wraparound);
361        for wkt_value in wkt_values {
362            let wkt: Wkt = Wkt::from_str(wkt_value.as_ref())
363                .map_err(|e| ArrowError::InvalidArgumentError(e.to_string()))?;
364            bounder.update_geometry(&wkt)?;
365        }
366        Ok(bounder)
367    }
368
369    #[test]
370    fn test_wkb() {
371        let wkt: Wkt = Wkt::from_str("LINESTRING (0 1, 2 3)").unwrap();
372        let mut wkb = Vec::new();
373        wkb::writer::write_geometry(&mut wkb, &wkt, &Default::default()).unwrap();
374
375        let mut bounds = GeometryBounder::empty();
376        bounds.update_wkb(&wkb).unwrap();
377
378        assert_eq!(bounds.x(), (0, 2).into());
379        assert_eq!(bounds.y(), (1, 3).into());
380    }
381
382    #[test]
383    fn test_geometry_types() {
384        let empties = [
385            "POINT EMPTY",
386            "LINESTRING EMPTY",
387            "POLYGON EMPTY",
388            "MULTIPOINT EMPTY",
389            "MULTILINESTRING EMPTY",
390            "MULTIPOLYGON EMPTY",
391            "GEOMETRYCOLLECTION EMPTY",
392        ];
393
394        assert_eq!(
395            wkt_bounds(empties).unwrap().geometry_types(),
396            vec![1, 2, 3, 4, 5, 6, 7]
397        );
398
399        let empties_z = [
400            "POINT Z EMPTY",
401            "LINESTRING Z EMPTY",
402            "POLYGON Z EMPTY",
403            "MULTIPOINT Z EMPTY",
404            "MULTILINESTRING Z EMPTY",
405            "MULTIPOLYGON Z EMPTY",
406            "GEOMETRYCOLLECTION Z EMPTY",
407        ];
408
409        assert_eq!(
410            wkt_bounds(empties_z).unwrap().geometry_types(),
411            vec![1001, 1002, 1003, 1004, 1005, 1006, 1007]
412        );
413
414        let empties_m = [
415            "POINT M EMPTY",
416            "LINESTRING M EMPTY",
417            "POLYGON M EMPTY",
418            "MULTIPOINT M EMPTY",
419            "MULTILINESTRING M EMPTY",
420            "MULTIPOLYGON M EMPTY",
421            "GEOMETRYCOLLECTION M EMPTY",
422        ];
423
424        assert_eq!(
425            wkt_bounds(empties_m).unwrap().geometry_types(),
426            vec![2001, 2002, 2003, 2004, 2005, 2006, 2007]
427        );
428
429        let empties_zm = [
430            "POINT ZM EMPTY",
431            "LINESTRING ZM EMPTY",
432            "POLYGON ZM EMPTY",
433            "MULTIPOINT ZM EMPTY",
434            "MULTILINESTRING ZM EMPTY",
435            "MULTIPOLYGON ZM EMPTY",
436            "GEOMETRYCOLLECTION ZM EMPTY",
437        ];
438
439        assert_eq!(
440            wkt_bounds(empties_zm).unwrap().geometry_types(),
441            vec![3001, 3002, 3003, 3004, 3005, 3006, 3007]
442        );
443    }
444
445    #[test]
446    fn test_bounds_empty() {
447        let empties = [
448            "POINT EMPTY",
449            "LINESTRING EMPTY",
450            "POLYGON EMPTY",
451            "MULTIPOINT EMPTY",
452            "MULTILINESTRING EMPTY",
453            "MULTIPOLYGON EMPTY",
454            "GEOMETRYCOLLECTION EMPTY",
455        ];
456
457        let bounds = wkt_bounds(empties).unwrap();
458        assert!(bounds.x().is_empty());
459        assert!(bounds.y().is_empty());
460        assert!(bounds.z().is_empty());
461        assert!(bounds.m().is_empty());
462
463        // With wraparound, still empty
464        let bounds = wkt_bounds_with_wraparound(empties, (-180, 180)).unwrap();
465        assert!(bounds.x().is_empty());
466        assert!(bounds.y().is_empty());
467        assert!(bounds.z().is_empty());
468        assert!(bounds.m().is_empty());
469    }
470
471    #[test]
472    fn test_bounds_coord() {
473        let bounds = wkt_bounds(["POINT (0 1)", "POINT (2 3)"]).unwrap();
474        assert_eq!(bounds.x(), (0, 2).into());
475        assert_eq!(bounds.y(), (1, 3).into());
476        assert!(bounds.z().is_empty());
477        assert!(bounds.m().is_empty());
478
479        let bounds = wkt_bounds(["POINT Z (0 1 2)", "POINT Z (3 4 5)"]).unwrap();
480        assert_eq!(bounds.x(), (0, 3).into());
481        assert_eq!(bounds.y(), (1, 4).into());
482        assert_eq!(bounds.z(), (2, 5).into());
483        assert!(bounds.m().is_empty());
484
485        let bounds = wkt_bounds(["POINT M (0 1 2)", "POINT M (3 4 5)"]).unwrap();
486        assert_eq!(bounds.x(), (0, 3).into());
487        assert_eq!(bounds.y(), (1, 4).into());
488        assert!(bounds.z().is_empty());
489        assert_eq!(bounds.m(), (2, 5).into());
490
491        let bounds = wkt_bounds(["POINT ZM (0 1 2 3)", "POINT ZM (4 5 6 7)"]).unwrap();
492        assert_eq!(bounds.x(), (0, 4).into());
493        assert_eq!(bounds.y(), (1, 5).into());
494        assert_eq!(bounds.z(), (2, 6).into());
495        assert_eq!(bounds.m(), (3, 7).into());
496    }
497
498    #[test]
499    fn test_bounds_sequence() {
500        let bounds = wkt_bounds(["LINESTRING (0 1, 2 3)"]).unwrap();
501        assert_eq!(bounds.x(), (0, 2).into());
502        assert_eq!(bounds.y(), (1, 3).into());
503        assert!(bounds.z().is_empty());
504        assert!(bounds.m().is_empty());
505
506        let bounds = wkt_bounds(["LINESTRING Z (0 1 2, 3 4 5)"]).unwrap();
507        assert_eq!(bounds.x(), (0, 3).into());
508        assert_eq!(bounds.y(), (1, 4).into());
509        assert_eq!(bounds.z(), (2, 5).into());
510        assert!(bounds.m().is_empty());
511
512        let bounds = wkt_bounds(["LINESTRING M (0 1 2, 3 4 5)"]).unwrap();
513        assert_eq!(bounds.x(), (0, 3).into());
514        assert_eq!(bounds.y(), (1, 4).into());
515        assert!(bounds.z().is_empty());
516        assert_eq!(bounds.m(), (2, 5).into());
517
518        let bounds = wkt_bounds(["LINESTRING ZM (0 1 2 3, 4 5 6 7)"]).unwrap();
519        assert_eq!(bounds.x(), (0, 4).into());
520        assert_eq!(bounds.y(), (1, 5).into());
521        assert_eq!(bounds.z(), (2, 6).into());
522        assert_eq!(bounds.m(), (3, 7).into());
523    }
524
525    #[test]
526    fn test_bounds_geometry_type() {
527        let bounds = wkt_bounds(["POINT (0 1)", "POINT (2 3)"]).unwrap();
528        assert_eq!(bounds.x(), (0, 2).into());
529        assert_eq!(bounds.y(), (1, 3).into());
530
531        let bounds = wkt_bounds(["LINESTRING (0 1, 2 3)"]).unwrap();
532        assert_eq!(bounds.x(), (0, 2).into());
533        assert_eq!(bounds.y(), (1, 3).into());
534
535        // Normally interiors are supposed to be inside the exterior; however, we
536        // include a poorly formed polygon just to make sure they are considered
537        let bounds =
538            wkt_bounds(["POLYGON ((0 0, 0 1, 1 0, 0 0), (10 10, 10 11, 11 10, 10 10))"]).unwrap();
539        assert_eq!(bounds.x(), (0, 11).into());
540        assert_eq!(bounds.y(), (0, 11).into());
541
542        let bounds = wkt_bounds(["MULTIPOINT ((0 1), (2 3))"]).unwrap();
543        assert_eq!(bounds.x(), (0, 2).into());
544        assert_eq!(bounds.y(), (1, 3).into());
545
546        let bounds = wkt_bounds(["MULTILINESTRING ((0 1, 2 3))"]).unwrap();
547        assert_eq!(bounds.x(), (0, 2).into());
548        assert_eq!(bounds.y(), (1, 3).into());
549
550        let bounds = wkt_bounds(["MULTIPOLYGON (((0 0, 0 1, 1 0, 0 0)))"]).unwrap();
551        assert_eq!(bounds.x(), (0, 1).into());
552        assert_eq!(bounds.y(), (0, 1).into());
553
554        let bounds = wkt_bounds(["GEOMETRYCOLLECTION (POINT (0 1), POINT (2 3))"]).unwrap();
555        assert_eq!(bounds.x(), (0, 2).into());
556        assert_eq!(bounds.y(), (1, 3).into());
557    }
558
559    #[test]
560    fn test_bounds_wrap_basic() {
561        let geoms = ["POINT (-170 0)", "POINT (170 0)"];
562
563        // No wraparound because it was disabled
564        let bounds = wkt_bounds_with_wraparound(geoms, Interval::empty()).unwrap();
565        assert_eq!(bounds.x(), (-170, 170).into());
566
567        // Wraparound that can't happen because something is covering
568        // the midpoint.
569        let mut geoms_with_mid = geoms.to_vec();
570        geoms_with_mid.push("LINESTRING (-10 0, 10 0)");
571        let bounds = wkt_bounds_with_wraparound(geoms_with_mid, (-180, 180)).unwrap();
572        assert_eq!(bounds.x(), (-170, 170).into());
573
574        // Wraparound where the wrapped box is *not* better
575        let bounds = wkt_bounds_with_wraparound(geoms, (-1000, 1000)).unwrap();
576        assert_eq!(bounds.x(), (-170, 170).into());
577
578        // Wraparound where the wrapped box is inappropriate because it is
579        // outside the wrap hint
580        let bounds = wkt_bounds_with_wraparound(geoms, (-10, 10)).unwrap();
581        assert_eq!(bounds.x(), (-170, 170).into());
582
583        // Wraparound where the wrapped box *is* better
584        let bounds = wkt_bounds_with_wraparound(geoms, (-180, 180)).unwrap();
585        assert_eq!(bounds.x(), (170, -170).into());
586
587        // The Cartesian bounds are tighter than the wraparound bounds.
588        let geoms = [
589            "POINT (-10 0)",
590            "POINT (-2 0)",
591            "POINT (170 0)",
592            "POINT (175 0)",
593        ];
594        let bounds = wkt_bounds_with_wraparound(geoms, (-180, 180)).unwrap();
595        assert_eq!(bounds.x(), (-10, 175).into());
596    }
597
598    #[test]
599    fn test_bounds_wrap_multipart() {
600        let fiji = "MULTIPOLYGON (
601        ((-180 -15.51, -180 -19.78, -178.61 -21.14, -178.02 -18.22, -178.57 -16.04, -180 -15.51)),
602        ((180 -15.51, 177.98 -16.25, 176.67 -17.14, 177.83 -19.31, 180 -19.78, 180 -15.51))
603        )";
604
605        let bounds = wkt_bounds_with_wraparound([fiji], (-180, 180)).unwrap();
606        assert!(bounds.x().is_wraparound());
607        assert_eq!(bounds.x(), (176.67, -178.02).into());
608        assert_eq!(bounds.y(), (-21.14, -15.51).into());
609    }
610}