h3o/geom/
tiler.rs

1use super::neighbors;
2use crate::{CellIndex, LatLng, Resolution, TWO_PI, error::InvalidGeometry};
3use ahash::{HashSet, HashSetExt};
4use either::Either;
5use float_eq::float_eq;
6use geo::{
7    BooleanOps as _, BoundingRect as _, Centroid as _, Coord, CoordsIter as _,
8    Intersects, Line, LineString, MultiPolygon, Polygon, Rect, Relate as _,
9    ToRadians as _,
10    algorithm::{
11        coordinate_position::{CoordPos, coord_pos_relative_to_ring},
12        relate::PreparedGeometry,
13    },
14    coord,
15};
16use std::{
17    cmp,
18    f64::consts::{FRAC_PI_2, PI},
19};
20
21/// A tiler that produces an H3 coverage of the given shapes.
22#[derive(Debug, Clone)]
23pub struct Tiler {
24    resolution: Resolution,
25    containment_mode: ContainmentMode,
26    convert_to_rads: bool,
27    transmeridian_heuristic_enabled: bool,
28    geom: MultiPolygon,
29}
30
31/// A cell, part of a geometry coverage, with annotations.
32pub struct AnnotatedCell {
33    /// The H3 cell index.
34    pub cell: CellIndex,
35
36    /// Whether the cell is fully contained by the geometry.
37    ///
38    /// Note: when using [`ContainmentMode::ContainsCentroid`], this value
39    /// is always `true`, since containment is determined by the cell's centroid
40    /// rather than its full area.
41    pub is_fully_contained: bool,
42}
43
44impl Tiler {
45    /// Adds a `Polygon` to tile.
46    ///
47    /// # Errors
48    ///
49    /// [`InvalidGeometry`] if the polygon is invalid.
50    pub fn add(&mut self, mut polygon: Polygon) -> Result<(), InvalidGeometry> {
51        // Convert to radians if necessary.
52        if self.convert_to_rads {
53            polygon.to_radians_in_place();
54        }
55
56        // Check coordinates validity.
57        ring_is_valid(polygon.exterior())?;
58        for interior in polygon.interiors() {
59            ring_is_valid(interior)?;
60        }
61
62        // Identify and fix transmeridian polygon if necessary.
63        if self.transmeridian_heuristic_enabled && is_transmeridian(&polygon) {
64            for fixed_polygon in fix_transmeridian(polygon).0 {
65                self.geom.0.push(fixed_polygon);
66            }
67        } else {
68            self.geom.0.push(polygon);
69        }
70
71        Ok(())
72    }
73
74    /// Adds a batch of `Polygon` to tile.
75    ///
76    /// # Errors
77    ///
78    /// [`InvalidGeometry`] if one of the polygon is invalid.
79    pub fn add_batch(
80        &mut self,
81        geoms: impl IntoIterator<Item = Polygon>,
82    ) -> Result<(), InvalidGeometry> {
83        for polygon in geoms {
84            self.add(polygon)?;
85        }
86        Ok(())
87    }
88
89    /// Returns an upper bound to the number of cells returned by `into_coverage`.
90    ///
91    /// # Example
92    ///
93    /// ```rust
94    /// use geo::{LineString, Polygon};
95    /// use h3o::{geom::{ContainmentMode, TilerBuilder}, Resolution};
96    ///
97    /// let polygon = Polygon::new(
98    ///     LineString::from(vec![(0., 0.), (1., 1.), (1., 0.), (0., 0.)]),
99    ///     vec![],
100    /// );
101    /// let mut tiler = TilerBuilder::new(Resolution::Six)
102    ///     .containment_mode(ContainmentMode::Covers)
103    ///     .build();
104    /// tiler.add(polygon)?;
105    ///
106    /// let size_hint = tiler.coverage_size_hint();
107    ///
108    /// # Ok::<(), h3o::error::InvalidGeometry>(())
109    /// ```
110    #[must_use]
111    pub fn coverage_size_hint(&self) -> usize {
112        const POLYGON_TO_CELLS_BUFFER: usize = 12;
113
114        self.geom
115            .iter()
116            .map(|polygon| {
117                let estimated_count = bbox_hex_estimate(
118                    &polygon.bounding_rect().expect("valid polygon"),
119                    self.resolution,
120                );
121
122                // This algorithm assumes that the number of vertices is usually less
123                // than the number of hexagons, but when it's wrong, this will keep it
124                // from failing.
125                let vertex_count = polygon
126                    .interiors()
127                    .iter()
128                    .chain(std::iter::once(polygon.exterior()))
129                    // -1 because the last coord is duplicated to close the ring.
130                    .map(|line| line.coords_count() - 1)
131                    .sum();
132
133                // When the polygon is very small, near an icosahedron edge and is an
134                // odd resolution, the line tracing needs an extra buffer than the
135                // estimator function provides (but beefing that up to cover causes most
136                // situations to overallocate memory)
137                cmp::max(estimated_count, vertex_count)
138                    + POLYGON_TO_CELLS_BUFFER
139            })
140            .sum()
141    }
142
143    /// Computes the cell coverage of the geometries.
144    ///
145    /// The output may contain duplicate indexes in case of overlapping input
146    /// geometries/depending on the selected containment mode.
147    ///
148    /// # Example
149    ///
150    /// ```rust
151    /// use geo::{LineString, Polygon};
152    /// use h3o::{geom::{ContainmentMode, TilerBuilder}, Resolution};
153    ///
154    /// let polygon = Polygon::new(
155    ///     LineString::from(vec![(0., 0.), (1., 1.), (1., 0.), (0., 0.)]),
156    ///     vec![],
157    /// );
158    /// let mut tiler = TilerBuilder::new(Resolution::Six)
159    ///     .containment_mode(ContainmentMode::Covers)
160    ///     .build();
161    /// tiler.add(polygon)?;
162    ///
163    /// let cells = tiler.into_coverage().collect::<Vec<_>>();
164    ///
165    /// # Ok::<(), h3o::error::InvalidGeometry>(())
166    /// ```
167    pub fn into_coverage(self) -> impl Iterator<Item = CellIndex> {
168        self.into_annotated_coverage().map(|value| value.cell)
169    }
170
171    /// Computes the annotated cell coverage of the geometries.
172    ///
173    /// The output may contain duplicate indexes in case of overlapping input
174    /// geometries/depending on the selected containment mode.
175    ///
176    /// # Example
177    ///
178    /// ```rust
179    /// use geo::{LineString, Polygon};
180    /// use h3o::{geom::{ContainmentMode, TilerBuilder}, Resolution};
181    ///
182    /// let polygon = Polygon::new(
183    ///     LineString::from(vec![(0., 0.), (1., 1.), (1., 0.), (0., 0.)]),
184    ///     vec![],
185    /// );
186    /// let mut tiler = TilerBuilder::new(Resolution::Six)
187    ///     .containment_mode(ContainmentMode::Covers)
188    ///     .build();
189    /// tiler.add(polygon)?;
190    ///
191    /// let cells = tiler.into_annotated_coverage().collect::<Vec<_>>();
192    ///
193    /// # Ok::<(), h3o::error::InvalidGeometry>(())
194    /// ```
195    pub fn into_annotated_coverage(
196        self,
197    ) -> impl Iterator<Item = AnnotatedCell> {
198        // This implementation traces the outlines of the polygon's rings, fill one
199        // layer of internal cells and then propagate inwards until the whole area
200        // is covered.
201        //
202        // Only the outlines and the first inner layer of cells requires
203        // Point-in-Polygon checks, inward propagation doesn't (since we're bounded
204        // by the outlines) which make this approach relatively efficient.
205
206        let predicate =
207            ContainmentPredicate::new(&self.geom, self.containment_mode);
208        // Set used for dedup.
209        let mut seen = HashSet::new();
210        // Scratchpad memory to store a cell and its immediate neighbors.
211        // Cell itself + at most 6 neighbors = 7.
212        let mut scratchpad = [0; 7];
213
214        // First, compute the outline.
215        let mut outlines = self.hex_outline(
216            self.resolution,
217            &mut seen,
218            &mut scratchpad,
219            &predicate,
220        );
221
222        if outlines.is_empty()
223            && self.containment_mode == ContainmentMode::Covers
224        {
225            let centroid = self.geom.centroid().expect("centroid");
226            return Either::Left(std::iter::once(AnnotatedCell {
227                cell: LatLng::from_radians(centroid.y(), centroid.x())
228                    .expect("valid coordinate")
229                    .to_cell(self.resolution),
230                is_fully_contained: false,
231            }));
232        }
233
234        // Next, compute the outermost layer of inner cells to seed the
235        // propagation step.
236        let mut candidates = outermost_inner_cells(
237            &outlines,
238            &mut seen,
239            &mut scratchpad,
240            &predicate,
241        );
242        let mut next_gen = Vec::with_capacity(candidates.len() * 7);
243        let mut new_seen = HashSet::with_capacity(seen.len());
244
245        if self.containment_mode == ContainmentMode::ContainsBoundary {
246            outlines.retain(|&(_, is_fully_contained)| is_fully_contained);
247            candidates.retain(|&(_, is_fully_contained)| is_fully_contained);
248        }
249
250        // Last step: inward propagation from the outermost layers.
251        let inward_propagation = std::iter::from_fn(move || {
252            if candidates.is_empty() {
253                return None;
254            }
255
256            for &(cell, _) in &candidates {
257                debug_assert!(
258                    self.geom.relate(&cell_boundary(cell)).is_covers(),
259                    "cell index {cell} in polygon"
260                );
261
262                let count = neighbors(cell, &mut scratchpad);
263                next_gen.extend(scratchpad[0..count].iter().filter_map(
264                    |candidate| {
265                        // SAFETY: candidate comes from `ring_disk_*`.
266                        let index = CellIndex::new_unchecked(*candidate);
267                        new_seen.insert(index);
268                        seen.insert(index).then_some((index, true))
269                    },
270                ));
271            }
272
273            let curr_gen = candidates.clone();
274
275            std::mem::swap(&mut next_gen, &mut candidates);
276            next_gen.clear();
277
278            std::mem::swap(&mut new_seen, &mut seen);
279            new_seen.clear();
280
281            Some(curr_gen.into_iter())
282        });
283
284        Either::Right(
285            outlines
286                .into_iter()
287                .chain(inward_propagation.flatten())
288                .map(|(cell, is_fully_contained)| AnnotatedCell {
289                    cell,
290                    is_fully_contained,
291                }),
292        )
293    }
294
295    // Return the cell indexes that traces the ring outline.
296    fn hex_outline(
297        &self,
298        resolution: Resolution,
299        already_seen: &mut HashSet<CellIndex>,
300        scratchpad: &mut [u64],
301        predicate: &ContainmentPredicate<'_>,
302    ) -> Vec<(CellIndex, bool)> {
303        // Compute the set of cells making the outlines of the polygon.
304        let outlines = self
305            .interiors()
306            .chain(self.exteriors())
307            .flat_map(|ring| get_edge_cells(ring, resolution))
308            .filter(|cell| already_seen.insert(*cell))
309            .collect::<Vec<_>>();
310        // Reset the `already_seen` set: content can't be trusted because we
311        // `get_edge_cells` is based on a rough approximation.
312        already_seen.clear();
313
314        // Buffer the initial outlines with immediate neighbors, (since we used
315        // a rough approximation, some cells from the initial set may be just
316        // out of the polygon).
317        outlines.into_iter().fold(Vec::new(), |mut acc, cell| {
318            let count = neighbors(cell, scratchpad);
319
320            acc.extend(scratchpad[0..count].iter().filter_map(|candidate| {
321                // SAFETY: candidate comes from `ring_disk_*`.
322                let index = CellIndex::new_unchecked(*candidate);
323
324                already_seen
325                    .insert(index)
326                    .then_some(index)
327                    .and_then(|index| {
328                        let result = predicate.apply(index);
329                        result
330                            .is_a_match
331                            .then_some((index, result.is_fully_contained))
332                    })
333            }));
334
335            acc
336        })
337    }
338
339    /// Returns the exterior rings of each polygon.
340    fn exteriors(&self) -> impl Iterator<Item = &LineString> {
341        self.geom.0.iter().map(Polygon::exterior)
342    }
343
344    /// Returns the interior rings of each polygon.
345    fn interiors(&self) -> impl Iterator<Item = &LineString> {
346        self.geom
347            .0
348            .iter()
349            .flat_map(|polygon| polygon.interiors().iter())
350    }
351}
352
353// -----------------------------------------------------------------------------
354
355/// A builder to configure a tiler.
356pub struct TilerBuilder {
357    resolution: Resolution,
358    containment_mode: ContainmentMode,
359    convert_to_rads: bool,
360    transmeridian_heuristic_enabled: bool,
361}
362
363impl TilerBuilder {
364    /// Initializes a new tiler builder with default settings.
365    #[must_use]
366    pub const fn new(resolution: Resolution) -> Self {
367        Self {
368            resolution,
369            containment_mode: ContainmentMode::ContainsCentroid,
370            convert_to_rads: true,
371            transmeridian_heuristic_enabled: true,
372        }
373    }
374
375    /// Disable the degrees-to-radians conversion pre-processing.
376    #[must_use]
377    pub const fn disable_radians_conversion(mut self) -> Self {
378        self.convert_to_rads = false;
379        self
380    }
381
382    /// Set the containment mode defining if a cell is in a polygon or not.
383    #[must_use]
384    pub const fn containment_mode(mut self, mode: ContainmentMode) -> Self {
385        self.containment_mode = mode;
386        self
387    }
388
389    /// Disable the transmeridian heuristic.
390    ///
391    /// It's safe to do if you are sure that none of the input polygon are
392    /// spread over the 180th meridian.
393    /// It's necessary to disable if you have a non-transmeridian polygon with
394    /// an arc greater than 180° (which would trip the heuristic).
395    #[must_use]
396    pub const fn disable_transmeridian_heuristic(mut self) -> Self {
397        self.transmeridian_heuristic_enabled = false;
398        self
399    }
400
401    /// Builds the tiler.
402    #[must_use]
403    pub fn build(self) -> Tiler {
404        Tiler {
405            resolution: self.resolution,
406            containment_mode: self.containment_mode,
407            convert_to_rads: self.convert_to_rads,
408            transmeridian_heuristic_enabled: self
409                .transmeridian_heuristic_enabled,
410            geom: MultiPolygon::new(Vec::new()),
411        }
412    }
413}
414
415// -----------------------------------------------------------------------------
416
417/// Containment mode used to decide if a cell is contained in a polygon or not.
418#[non_exhaustive]
419#[derive(Clone, Copy, Debug, Eq, PartialEq)]
420pub enum ContainmentMode {
421    /// This mode will select every cells whose centroid are contained inside
422    /// the polygon.
423    ///
424    /// This is the fasted option and ensures that every cell is uniquely
425    /// assigned (e.g. two adjacent polygon with zero overlap also have zero
426    /// overlapping cells).
427    ///
428    /// On the other hand, some cells may cover area outside of the polygon
429    /// (overshooting) and some parts of the polygon may be left uncovered.
430    ContainsCentroid,
431
432    /// This mode will select every cells whose boundaries are entirely within
433    /// the polygon.
434    ///
435    /// This ensures that every cell is uniquely assigned  (e.g. two adjacent
436    /// polygon with zero overlap also have zero overlapping cells) and avoids
437    /// any coverage overshooting.
438    ///
439    /// Some parts of the polygon may be left uncovered (more than with
440    /// `ContainsCentroid`).
441    ContainsBoundary,
442
443    /// This mode will select every cells whose boundaries are within the
444    /// polygon, even partially.
445    ///
446    /// This guarantees a complete coverage of the polygon, but some cells may
447    /// belong to two different polygons if they are adjacent/close enough. Some
448    /// cells may cover area outside of the polygon.
449    ///
450    /// Note that if the geometry is fully contained within a cell, this mode
451    /// returns nothing (because there are no boundaries intersection).
452    IntersectsBoundary,
453
454    /// This mode behaves the same as `IntersectsBoundary`, but also handles the
455    /// case where the geometry is being covered by a cell without intersecting
456    /// with its boundaries. In such cases, the covering cell is returned.
457    Covers,
458}
459
460/// The result of a predicate application.
461struct PredicateResult {
462    /// Whether the cell is a match for this predicate.
463    is_a_match: bool,
464    /// Whether the cell is fully contained by the geometry.
465    ///
466    /// This is especially useful to quickly prune intersecting cells in
467    /// Contains mode.
468    is_fully_contained: bool,
469}
470
471#[expect(clippy::large_enum_variant, reason = "used once, on the stack")]
472enum ContainmentPredicate<'geom> {
473    ContainsCentroid(&'geom MultiPolygon, MultiBBoxes),
474    IntersectsBoundary(PreparedGeometry<'geom, &'geom MultiPolygon>),
475}
476
477impl<'geom> ContainmentPredicate<'geom> {
478    /// Initializes a new containment predicate according to the mode selected.
479    fn new(
480        geom: &'geom MultiPolygon,
481        containment_mode: ContainmentMode,
482    ) -> Self {
483        match containment_mode {
484            // For this one we can use our good ol' PIP-based approach.
485            ContainmentMode::ContainsCentroid => {
486                // Pre-compute the bounding boxes for each ring.
487                let bboxes = MultiBBoxes(
488                    geom.iter()
489                        .map(|polygon| BBoxes {
490                            exterior: polygon
491                                .exterior()
492                                .bounding_rect()
493                                .expect("exterior bbox"),
494                            interiors: polygon
495                                .interiors()
496                                .iter()
497                                .map(|ring| {
498                                    ring.bounding_rect().expect("interior bbox")
499                                })
500                                .collect(),
501                        })
502                        .collect(),
503                );
504
505                Self::ContainsCentroid(geom, bboxes)
506            }
507            // For the others, using a related-based approach boosted by a
508            // PreparedGeometry is the way to go.
509            ContainmentMode::ContainsBoundary
510            | ContainmentMode::IntersectsBoundary
511            | ContainmentMode::Covers => {
512                let prepared_geom = PreparedGeometry::from(geom);
513                Self::IntersectsBoundary(prepared_geom)
514            }
515        }
516    }
517
518    /// Applies the predicate with the given cell.
519    fn apply(&self, cell: CellIndex) -> PredicateResult {
520        match self {
521            Self::ContainsCentroid(geom, bboxes) => {
522                let ll = LatLng::from(cell);
523                let coord = coord! { x: ll.lng_radians(), y: ll.lat_radians() };
524
525                let is_a_match =
526                    geom.iter().enumerate().any(|(poly_idx, polygon)| {
527                        ring_contains_centroid(
528                            polygon.exterior(),
529                            &bboxes.0[poly_idx].exterior,
530                            coord,
531                        ) && !polygon.interiors().iter().enumerate().any(
532                            |(ring_idx, ring)| {
533                                ring_contains_centroid(
534                                    ring,
535                                    &bboxes.0[poly_idx].interiors[ring_idx],
536                                    coord,
537                                )
538                            },
539                        )
540                    });
541
542                PredicateResult {
543                    is_a_match,
544                    is_fully_contained: true,
545                }
546            }
547            Self::IntersectsBoundary(geom) => {
548                let boundary = cell_boundary(cell);
549                let relation = geom.relate(&boundary);
550
551                PredicateResult {
552                    is_a_match: relation.is_intersects(),
553                    is_fully_contained: relation.is_covers(),
554                }
555            }
556        }
557    }
558}
559
560// -----------------------------------------------------------------------------
561
562// Compute the outermost layer of inner cells.
563//
564// Those are the last ones that requires a PiP check, due to their
565// proximity with the outline.
566fn outermost_inner_cells(
567    outlines: &[(CellIndex, bool)],
568    already_seen: &mut HashSet<CellIndex>,
569    scratchpad: &mut [u64],
570    predicate: &ContainmentPredicate<'_>,
571) -> Vec<(CellIndex, bool)> {
572    outlines.iter().fold(Vec::new(), |mut acc, &(cell, _)| {
573        let count = neighbors(cell, scratchpad);
574
575        acc.extend(scratchpad[0..count].iter().filter_map(|candidate| {
576            // SAFETY: candidate comes from `ring_disk_*`.
577            let index = CellIndex::new_unchecked(*candidate);
578
579            already_seen
580                .insert(index)
581                .then_some(index)
582                .and_then(|index| {
583                    let result = predicate.apply(index);
584                    result
585                        .is_a_match
586                        .then_some((index, result.is_fully_contained))
587                })
588        }));
589        acc
590    })
591}
592
593// Return the cell indexes that traces the ring outline (rough approximation)
594fn get_edge_cells(
595    ring: &LineString,
596    resolution: Resolution,
597) -> impl Iterator<Item = CellIndex> + '_ {
598    ring.lines().flat_map(move |line @ Line { start, end }| {
599        let count = line_hex_estimate(&line, resolution);
600
601        assert!(count <= 1 << f64::MANTISSA_DIGITS);
602        #[expect(
603            clippy::cast_precision_loss,
604            reason = "cannot happen thanks to assert above"
605        )]
606        (0..count).map(move |i| {
607            let i = i as f64;
608            let count = count as f64;
609
610            let lat = (start.y * (count - i) / count) + (end.y * i / count);
611            let lng = (start.x * (count - i) / count) + (end.x * i / count);
612
613            LatLng::from_radians(lat, lng)
614                .expect("finite line coordinate")
615                .to_cell(resolution)
616        })
617    })
618}
619
620/// Returns an estimated number of hexagons that trace the cartesian-projected
621/// line.
622fn line_hex_estimate(line: &Line, resolution: Resolution) -> u64 {
623    // Get the area of the pentagon as the maximally-distorted area possible
624    const PENT_DIAMETER_RADS: [f64; 16] = [
625        0.32549355508382627,
626        0.11062000431697926,
627        0.0431531246375496,
628        0.015280278825461551,
629        0.006095981694441515,
630        0.00217237586248339,
631        0.0008694532999397082,
632        0.0003101251537809772,
633        0.00012417902430910614,
634        0.00004429922220615181,
635        0.00001773927716796858,
636        0.000006328371112691009,
637        0.0000025341705472716865,
638        0.0000009040511973807097,
639        0.00000036202412300873475,
640        0.00000012915013523209886,
641    ];
642    let pentagon_diameter = PENT_DIAMETER_RADS[usize::from(resolution)];
643
644    let origin = LatLng::from_radians(line.start.y, line.start.x)
645        .expect("finite line-start coordinate");
646    let destination = LatLng::from_radians(line.end.y, line.end.x)
647        .expect("finite line-end coordinate");
648    let distance = origin.distance_rads(destination);
649
650    let dist_ceil = (distance / pentagon_diameter).ceil();
651    assert!(dist_ceil.is_finite());
652
653    #[expect(
654        clippy::cast_possible_truncation,
655        clippy::cast_sign_loss,
656        reason = "truncate on purpose"
657    )]
658    let estimate = dist_ceil as u64;
659
660    cmp::max(estimate, 1)
661}
662
663/// Returns an estimated number of hexagons that fit within the
664/// cartesian-projected bounding box.
665pub fn bbox_hex_estimate(bbox: &Rect, resolution: Resolution) -> usize {
666    // Area of a regular hexagon is `3/2*sqrt(3) * r * r`.
667    //
668    // The pentagon has the most distortion (smallest edges) and shares its
669    // edges with hexagons, so the most-distorted hexagons have this area,
670    // shrunk by 20% off chance that the bounding box perfectly bounds a
671    // pentagon.
672    const PENT_AREA_RADS2: [f64; 16] = [
673        0.05505118472518226,
674        0.006358420186890303,
675        0.0009676234334810151,
676        0.00012132336301389888,
677        0.000019309418286620768,
678        0.0000024521770265310696,
679        0.0000003928026439666205,
680        0.00000004997535264470275,
681        0.000000008012690511075445,
682        0.0000000010197039091132572,
683        0.00000000016351353999538285,
684        0.000000000020809697203105007,
685        0.000000000003336979666606075,
686        0.0000000000004246859893033221,
687        0.00000000000006810153522091642,
688        0.000000000000008667056198238203,
689    ];
690    let pentagon_area_rads2 = PENT_AREA_RADS2[usize::from(resolution)];
691
692    let min = bbox.min();
693    let max = bbox.max();
694    let p1 =
695        LatLng::from_radians(min.y, min.x).expect("finite bbox-min coordinate");
696    let p2 =
697        LatLng::from_radians(max.y, max.x).expect("finite bbox-max coordinate");
698    let diagonal = p1.distance_rads(p2);
699    let d1 = (p1.lng_radians() - p2.lng_radians()).abs();
700    let d2 = (p1.lat_radians() - p2.lat_radians()).abs();
701    let (width, length) = if d1 < d2 { (d1, d2) } else { (d2, d1) };
702    // Derived constant based on: https://math.stackexchange.com/a/1921940
703    // Clamped to 3 as higher values tend to rapidly drag the estimate to zero.
704    #[expect(clippy::suspicious_operation_groupings, reason = "false positive")]
705    let area = (diagonal * diagonal) / (length / width);
706
707    // Divide the two to get an estimate of the number of hexagons needed.
708    let estimate = (area / pentagon_area_rads2).ceil();
709
710    #[expect(
711        clippy::cast_possible_truncation,
712        clippy::cast_sign_loss,
713        reason = "truncate on purpose"
714    )]
715    let estimate = estimate as usize;
716
717    cmp::max(estimate, 1)
718}
719
720// -----------------------------------------------------------------------------
721
722// Check for arcs > 180 degrees (π radians) longitude to flag as transmeridian.
723fn is_transmeridian(geom: &Polygon) -> bool {
724    geom.exterior()
725        .lines()
726        .any(|line| (line.start.x - line.end.x).abs() > PI)
727}
728
729// Fix a transmeridian polygon by splitting it into multiple polygons that are
730// on either side.
731fn fix_transmeridian(mut polygon: Polygon) -> MultiPolygon {
732    let west = Rect::new(
733        coord! { x: PI, y: -FRAC_PI_2},
734        coord! { x: TWO_PI, y: FRAC_PI_2},
735    )
736    .to_polygon();
737    let east = Rect::new(
738        coord! { x: 0., y: -FRAC_PI_2},
739        coord! { x: PI, y: FRAC_PI_2},
740    )
741    .to_polygon();
742
743    shift_transmeridian(&mut polygon);
744    let mut fixed = polygon.intersection(&west);
745    unshift_transmeridian(&mut fixed);
746    fix_clipping_boundary(&mut fixed, true);
747
748    let mut other = polygon.intersection(&east);
749    fix_clipping_boundary(&mut other, false);
750    fixed.0.extend(other.0);
751
752    fixed
753}
754
755/// Shift the coordinates of a polygon to the right of the 180th meridian.
756fn shift_transmeridian(geom: &mut Polygon) {
757    geom.exterior_mut(shift_transmeridian_ring);
758    geom.interiors_mut(|interiors| {
759        for interior in interiors {
760            shift_transmeridian_ring(interior);
761        }
762    });
763}
764
765/// Unshift the coordinates of a shifted polygon.
766fn unshift_transmeridian(geom: &mut MultiPolygon) {
767    for polygon in geom.iter_mut() {
768        polygon.exterior_mut(unshift_transmeridian_ring);
769        polygon.interiors_mut(|interiors| {
770            for interior in interiors {
771                unshift_transmeridian_ring(interior);
772            }
773        });
774    }
775}
776
777// Fix clipping boundary to be robust against rounding errors/imprecisions.
778fn fix_clipping_boundary(geom: &mut MultiPolygon, is_west: bool) {
779    for polygon in geom.iter_mut() {
780        polygon.exterior_mut(|exterior| {
781            fix_ring_clipping_boundary(exterior, is_west);
782        });
783        polygon.interiors_mut(|interiors| {
784            for interior in interiors {
785                fix_ring_clipping_boundary(interior, is_west);
786            }
787        });
788    }
789}
790
791// Check that a polygon ring is valid.
792pub fn ring_is_valid(ring: &LineString) -> Result<(), InvalidGeometry> {
793    // Closed ring have at least 4 coordinate (e.g. triangle).
794    if ring.0.len() < 4 {
795        return Err(InvalidGeometry::new(
796            "invalid ring (not enough coordinate)",
797        ));
798    }
799    if !ring.coords().all(|coord| super::coord_is_valid(*coord)) {
800        return Err(InvalidGeometry::new(
801            "every coordinate of the exterior must be valid",
802        ));
803    }
804
805    Ok(())
806}
807
808/// Shift the coordinates of a ring to the right of the 180th meridian.
809fn shift_transmeridian_ring(ring: &mut LineString) {
810    for coord in ring.coords_mut() {
811        coord.x += f64::from(coord.x < 0.) * TWO_PI;
812    }
813}
814
815/// Unshift the coordinates of a shifted ring.
816fn unshift_transmeridian_ring(ring: &mut LineString) {
817    for coord in ring.coords_mut() {
818        coord.x -= f64::from(coord.x >= PI) * TWO_PI;
819    }
820}
821
822// Fix points coordinates on the clipping boundary.
823//
824// Even though we clip at exactly -180/180°, due to rounding error the value
825// after clipping might be slightly different which can be a problem when
826// computing the intersection matrix.
827fn fix_ring_clipping_boundary(ring: &mut LineString, is_west: bool) {
828    const ROUNDING_EPSILON: f64 = 1e-6;
829    let (bad_value, fixed_value) = if is_west {
830        let mut bad_value = PI;
831        for coord in ring.coords() {
832            if float_eq!(coord.x, PI, abs <= ROUNDING_EPSILON) {
833                bad_value = coord.x;
834                break;
835            }
836            bad_value = bad_value.min(coord.x);
837        }
838        (bad_value, -PI)
839    } else {
840        let mut bad_value = -PI;
841        for coord in ring.coords() {
842            if float_eq!(coord.x, -PI, abs <= ROUNDING_EPSILON) {
843                bad_value = coord.x;
844                break;
845            }
846            bad_value = bad_value.max(coord.x);
847        }
848        (bad_value, PI)
849    };
850
851    #[expect(clippy::float_cmp, reason = "we want exact equality")]
852    for coord in ring.coords_mut() {
853        if coord.x == bad_value {
854            coord.x = fixed_value;
855        }
856    }
857}
858
859// The independant bounding boxes for a MultiPolygon.
860struct MultiBBoxes(Vec<BBoxes>);
861
862// The independant bounding boxes for a Polygon.
863struct BBoxes {
864    exterior: Rect,
865    interiors: Vec<Rect>,
866}
867
868// Simple Point-in-Polygon check for a ring.
869fn ring_contains_centroid(
870    ring: &LineString,
871    bbox: &Rect,
872    mut coord: Coord,
873) -> bool {
874    if !bbox.intersects(&coord) {
875        return false;
876    }
877
878    match coord_pos_relative_to_ring(coord, ring) {
879        CoordPos::Inside => true,
880        CoordPos::Outside => false,
881        CoordPos::OnBoundary => {
882            // If the centroid lies on a boundary, it could belong to two
883            // polygons.
884            // To avoid this, adjust the latitude northward.
885            //
886            // NOTE: This currently means that a point at the north pole cannot
887            // be contained in any polygon. This is acceptable in current usage,
888            // because the point we test in this function at present is always a
889            // cell center or vertex, and no cell has a center or vertex on the
890            // north pole. If we need to expand this algo to more generic uses
891            // we might need to handle this edge case.
892            coord.y += f64::EPSILON;
893            coord_pos_relative_to_ring(coord, ring) == CoordPos::Inside
894        }
895    }
896}
897
898// Return the cell boundary, in radians.
899pub fn cell_boundary(cell: CellIndex) -> MultiPolygon {
900    let boundary = LineString(
901        cell.boundary()
902            .iter()
903            .copied()
904            .map(|ll| {
905                coord! {
906                    x: ll.lng_radians(),
907                    y: ll.lat_radians()
908                }
909            })
910            .collect(),
911    );
912    let polygon = Polygon::new(boundary, Vec::new());
913    if is_transmeridian(&polygon) {
914        fix_transmeridian(polygon)
915    } else {
916        MultiPolygon::new(vec![polygon])
917    }
918}