diff --git a/CHANGELOG.md b/CHANGELOG.md index b71f7747..64fe3e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ avoid adding features or APIs which do not map onto the ## Unreleased +## [4.5.0a2] - 2026-02-27 + +- `cells_to_h3shape`/`cells_to_geo` now handle all valid cell sets, including + sets that would produce global polygons crossing the antimeridian, poles, or are larger than a hemisphere + - Duplicate cells now raise `H3DuplicateInputError` + - Mixed-resolution cells now raise `H3ResMismatchError` + - Internal: switch from linked-list to array-based C API for cells-to-polygon conversion + ## [4.5.0a1] - 2026-02-25 - Update `h3lib` with draft v4.5 changes diff --git a/pyproject.toml b/pyproject.toml index 3c9effe1..1da49184 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'scikit_build_core.build' [project] name = 'h3' -version = '4.5.0a1' +version = '4.5.0a2' description = "Uber's hierarchical hexagonal geospatial indexing system" readme = 'readme.md' license = {file = 'LICENSE'} diff --git a/src/h3/_cy/h3lib.pxd b/src/h3/_cy/h3lib.pxd index 8cf74a7e..7dfa0c55 100644 --- a/src/h3/_cy/h3lib.pxd +++ b/src/h3/_cy/h3lib.pxd @@ -47,21 +47,6 @@ cdef extern from 'h3api.h': int i int j - ctypedef struct LinkedLatLng: - LatLng data 'vertex' - LinkedLatLng *next - - # renaming these for clarity - ctypedef struct LinkedGeoLoop: - LinkedLatLng *data 'first' - LinkedLatLng *_data_last 'last' # not needed in Cython bindings - LinkedGeoLoop *next - - ctypedef struct LinkedGeoPolygon: - LinkedGeoLoop *data 'first' - LinkedGeoLoop *_data_last 'last' # not needed in Cython bindings - LinkedGeoPolygon *next - ctypedef struct GeoLoop: int numVerts LatLng *verts @@ -171,8 +156,8 @@ cdef extern from 'h3api.h': double greatCircleDistanceKm(const LatLng *a, const LatLng *b) nogil double greatCircleDistanceM(const LatLng *a, const LatLng *b) nogil - H3Error cellsToLinkedMultiPolygon(const H3int *h3Set, const int numCells, LinkedGeoPolygon *out) - void destroyLinkedMultiPolygon(LinkedGeoPolygon *polygon) + H3Error cellsToMultiPolygon(const H3int *cells, const int64_t numCells, GeoMultiPolygon *out) + void destroyGeoMultiPolygon(GeoMultiPolygon *mpoly) H3Error maxPolygonToCellsSize(const GeoPolygon *geoPolygon, int res, uint32_t flags, uint64_t *count) H3Error polygonToCells(const GeoPolygon *geoPolygon, int res, uint32_t flags, H3int *out) @@ -180,9 +165,9 @@ cdef extern from 'h3api.h': H3Error maxPolygonToCellsSizeExperimental(const GeoPolygon *geoPolygon, int res, uint32_t flags, uint64_t *count) H3Error polygonToCellsExperimental(const GeoPolygon *geoPolygon, int res, uint32_t flags, uint64_t sz, H3int *out) - # ctypedef struct GeoMultiPolygon: - # int numPolygons - # GeoPolygon *polygons + ctypedef struct GeoMultiPolygon: + int numPolygons + GeoPolygon *polygons # int hexRange(H3int origin, int k, H3int *out) diff --git a/src/h3/_cy/to_multipoly.pyx b/src/h3/_cy/to_multipoly.pyx index d24e2c3e..ed3e7637 100644 --- a/src/h3/_cy/to_multipoly.pyx +++ b/src/h3/_cy/to_multipoly.pyx @@ -4,60 +4,42 @@ from .util cimport check_cell, coord2deg from .error_system cimport check_for_error -# todo: it's driving me crazy that these three functions are all essentially the same linked list walker... -# grumble: no way to do iterators in with cdef functions! -cdef walk_polys(const h3lib.LinkedGeoPolygon* L): - out = [] - while L: - out += [walk_loops(L.data)] - L = L.next +cdef _loop_to_list(const h3lib.GeoLoop *loop): + return [coord2deg(loop.verts[v]) for v in range(loop.numVerts)] - return out - - -cdef walk_loops(const h3lib.LinkedGeoLoop* L): - out = [] - while L: - out += [walk_coords(L.data)] - L = L.next - - return out +cdef _poly_to_lists(const h3lib.GeoPolygon *poly): + return ( + [_loop_to_list(&poly.geoloop)] + + [_loop_to_list(&poly.holes[h]) for h in range(poly.numHoles)] + ) -cdef walk_coords(const h3lib.LinkedLatLng* L): - out = [] - while L: - out += [coord2deg(L.data)] - L = L.next - - return out -# todo: tuples instead of lists? def _to_multi_polygon(const H3int[:] cells): cdef: - h3lib.LinkedGeoPolygon polygon + h3lib.GeoMultiPolygon mpoly + H3int cell - for h in cells: - check_cell(h) + for cell in cells: + check_cell(cell) check_for_error( - h3lib.cellsToLinkedMultiPolygon(&cells[0], len(cells), &polygon) + h3lib.cellsToMultiPolygon(&cells[0], len(cells), &mpoly) ) - out = walk_polys(&polygon) - - # we're still responsible for cleaning up the passed in `polygon`, - # but not a problem here, since it is stack allocated - h3lib.destroyLinkedMultiPolygon(&polygon) + try: + out = [ + _poly_to_lists(&mpoly.polygons[p]) + for p in range(mpoly.numPolygons) + ] + finally: + h3lib.destroyGeoMultiPolygon(&mpoly) return out def cells_to_multi_polygon(const H3int[:] cells): - # todo: gotta be a more elegant way to handle these... if len(cells) == 0: return [] - multipoly = _to_multi_polygon(cells) - - return multipoly + return _to_multi_polygon(cells) diff --git a/tests/test_lib/polyfill/test_to_multipoly.py b/tests/test_lib/polyfill/test_to_multipoly.py index 311c8ca0..80c5a751 100644 --- a/tests/test_lib/polyfill/test_to_multipoly.py +++ b/tests/test_lib/polyfill/test_to_multipoly.py @@ -1,11 +1,21 @@ import pytest import h3 -from h3._cy.error_system import H3FailedError +from h3._cy.error_system import H3DuplicateInputError, H3ResMismatchError, H3FailedError from .. import util as u +def assert_cells_roundtrip(cells): + """Convert cells to multi-polygon and back, checking we recover the original set.""" + res = h3.get_resolution(cells[0]) + mpoly = h3.cells_to_h3shape(cells, tight=False) + + recovered = h3.h3shape_to_cells(mpoly, res) + + assert u.same_set(recovered, cells) + + def test_cells_to_h3shape(): h = '8928308280fffff' cells = h3.grid_disk(h, 1) @@ -51,53 +61,300 @@ def test_2_polys(): assert set(map(len, out)) == {1, 12} -def test_cells_to_h3shape_transmeridian_error(): +def test_empty(): + mpoly = h3.cells_to_h3shape([], tight=False) + assert isinstance(mpoly, h3.LatLngMultiPoly) + assert len(mpoly) == 0 + + mpoly = h3.cells_to_h3shape([], tight=True) + assert isinstance(mpoly, h3.LatLngMultiPoly) + assert len(mpoly) == 0 + + +def test_all_res0_cells(): + cells = h3.get_res0_cells() + mpoly = h3.cells_to_h3shape(cells, tight=False) + + # We should get an "entire globe" multipolygon back + # The current implementation returns 8 triangles + assert len(mpoly) == 8 + for poly in mpoly: + assert len(poly.outer) == 3 + assert len(poly.holes) == 0 + + # Roundtrip fails: h3shape_to_cells recovers only 69/122 cells. + # TODO: assert_cells_roundtrip when h3shape_to_cells handles global polygons. + cells2 = h3.h3shape_to_cells(mpoly, 0) + assert len(cells2) == 69 + + +def test_pentagons(): + for res in range(16): + cells = h3.get_pentagons(res) + mpoly = h3.cells_to_h3shape(cells, tight=False) + + if res % 2 == 0: + expected_verts = 5 + else: + # pentagons have distortion vertices at odd resolutions + expected_verts = 10 + + assert len(mpoly) == 12 + for poly in mpoly: + assert len(poly.holes) == 0 + assert len(poly.outer) == expected_verts + + assert_cells_roundtrip(cells) + + +def test_duplicate_cells_error(): + cell = '8928308280fffff' + with pytest.raises(H3DuplicateInputError): + h3.cells_to_h3shape([cell, cell]) + + +def test_mixed_resolution_error(): + with pytest.raises(H3ResMismatchError): + h3.cells_to_h3shape(['8027fffffffffff', '81efbffffffffff']) + + +def test_three_polygons(): + cells = [ + '8027fffffffffff', '802bfffffffffff', '804dfffffffffff', + '8067fffffffffff', '806dfffffffffff', '8049fffffffffff', + '805ffffffffffff', '8057fffffffffff', '807dfffffffffff', + '80a5fffffffffff', '80a9fffffffffff', '808bfffffffffff', + '801bfffffffffff', '8035fffffffffff', '803ffffffffffff', + '8053fffffffffff', '8043fffffffffff', '8021fffffffffff', + '8011fffffffffff', '801ffffffffffff', '8097fffffffffff', + ] + + mpoly = h3.cells_to_h3shape(cells, tight=False) + + assert len(mpoly) == 3 + hole_counts = [len(p.holes) for p in mpoly] + + # Polygons are ordered by size, and we know the hole counts of each + assert hole_counts == [3, 1, 0] + + assert_cells_roundtrip(cells) + + +def test_issue_1049(): + cells = [ + '827487fffffffff', '82748ffffffffff', '827497fffffffff', + '82749ffffffffff', '8274affffffffff', '8274c7fffffffff', + '8274cffffffffff', '8274d7fffffffff', '8274e7fffffffff', + '8274effffffffff', '8274f7fffffffff', '82754ffffffffff', + '827c07fffffffff', '827c27fffffffff', '827c2ffffffffff', + '827c37fffffffff', '827d87fffffffff', '827d8ffffffffff', + '827d97fffffffff', '827d9ffffffffff', '827da7fffffffff', + '827daffffffffff', '82801ffffffffff', '8280a7fffffffff', + '8280affffffffff', '8280b7fffffffff', '828197fffffffff', + '82819ffffffffff', '8281a7fffffffff', '8281b7fffffffff', + '828207fffffffff', '82820ffffffffff', '828227fffffffff', + '82822ffffffffff', '8282e7fffffffff', '828307fffffffff', + '82830ffffffffff', '82831ffffffffff', '82832ffffffffff', + '828347fffffffff', '82834ffffffffff', '828357fffffffff', + '82835ffffffffff', '828367fffffffff', '828377fffffffff', + '82a447fffffffff', '82a457fffffffff', '82a45ffffffffff', + '82a467fffffffff', '82a46ffffffffff', '82a477fffffffff', + '82a4c7fffffffff', '82a4cffffffffff', '82a4d7fffffffff', + '82a4e7fffffffff', '82a4effffffffff', '82a4f7fffffffff', + '82a547fffffffff', '82a54ffffffffff', '82a557fffffffff', + '82a55ffffffffff', '82a567fffffffff', '82a577fffffffff', + '82a837fffffffff', '82a897fffffffff', '82a8a7fffffffff', + '82a8b7fffffffff', '82a917fffffffff', '82a927fffffffff', + '82a937fffffffff', '82a987fffffffff', '82a98ffffffffff', + '82a997fffffffff', '82a99ffffffffff', '82a9a7fffffffff', + '82a9affffffffff', '82ac47fffffffff', '82ac57fffffffff', + '82ac5ffffffffff', '82ac67fffffffff', '82ac6ffffffffff', + '82ac77fffffffff', '82ad47fffffffff', '82ad4ffffffffff', + '82ad57fffffffff', '82ad5ffffffffff', '82ad67fffffffff', + '82ad77fffffffff', '82c207fffffffff', '82c217fffffffff', + '82c227fffffffff', '82c237fffffffff', '82c287fffffffff', + '82c28ffffffffff', '82c29ffffffffff', '82c2a7fffffffff', + '82c2affffffffff', '82c2b7fffffffff', '82c307fffffffff', + '82c317fffffffff', '82c31ffffffffff', '82c337fffffffff', + '82cfb7fffffffff', '82d0c7fffffffff', '82d0d7fffffffff', + '82d0dffffffffff', '82d0e7fffffffff', '82d0f7fffffffff', + '82d147fffffffff', '82d157fffffffff', '82d15ffffffffff', + '82d167fffffffff', '82d177fffffffff', '82d187fffffffff', + '82d18ffffffffff', '82d197fffffffff', '82d19ffffffffff', + '82d1a7fffffffff', '82d1affffffffff', '82dc47fffffffff', + '82dc57fffffffff', '82dc5ffffffffff', '82dc67fffffffff', + '82dc6ffffffffff', '82dc77fffffffff', '82dcc7fffffffff', + '82dccffffffffff', '82dcd7fffffffff', '82dce7fffffffff', + '82dceffffffffff', '82dcf7fffffffff', '82dd1ffffffffff', + '82dd47fffffffff', '82dd4ffffffffff', '82dd57fffffffff', + '82dd5ffffffffff', '82dd6ffffffffff', '82dd87fffffffff', + '82dd8ffffffffff', '82dd97fffffffff', '82dd9ffffffffff', + '82ddaffffffffff', '82ddb7fffffffff', '82dec7fffffffff', + '82decffffffffff', '82ded7fffffffff', '82dee7fffffffff', + '82deeffffffffff', '82def7fffffffff', '82df0ffffffffff', + '82df1ffffffffff', '82df47fffffffff', '82df4ffffffffff', + '82df57fffffffff', '82df5ffffffffff', '82df77fffffffff', + '82df8ffffffffff', '82df9ffffffffff', '82e6c7fffffffff', + '82e6cffffffffff', '82e6d7fffffffff', '82e6dffffffffff', + '82e6effffffffff', '82e6f7fffffffff', + ] + + mpoly = h3.cells_to_h3shape(cells, tight=False) + + assert len(mpoly) == 12 + for poly in mpoly: + assert len(poly.holes) == 0 + + assert_cells_roundtrip(cells) + + +def test_equator_cells(): + """Continuous band of cells overlapping the equator. """ - Test for https://github.com/uber/h3-py/issues/476 + cells = [ + '81807ffffffffff', '817efffffffffff', '81723ffffffffff', + '817ebffffffffff', '817c3ffffffffff', '817e3ffffffffff', + '817a3ffffffffff', '8166fffffffffff', '8172bffffffffff', + '816afffffffffff', '81933ffffffffff', '8168fffffffffff', + '8188fffffffffff', '81853ffffffffff', '817f7ffffffffff', + '8180bffffffffff', '81783ffffffffff', '81743ffffffffff', + '8170bffffffffff', '8173bffffffffff', '8179bffffffffff', + '817cbffffffffff', '8188bffffffffff', '81857ffffffffff', + '816f7ffffffffff', '8177bffffffffff', '81617ffffffffff', + '816f3ffffffffff', '8174bffffffffff', '8180fffffffffff', + '817a7ffffffffff', '81767ffffffffff', '81757ffffffffff', + '81957ffffffffff', '81787ffffffffff', '81847ffffffffff', + '81653ffffffffff', '817bbffffffffff', '816cfffffffffff', + '816abffffffffff', '815f3ffffffffff', '817c7ffffffffff', + '8168bffffffffff', '818cbffffffffff', '818cfffffffffff', + '818afffffffffff', '8174fffffffffff', '8172fffffffffff', + '8170fffffffffff', '816fbffffffffff', '81657ffffffffff', + '816c7ffffffffff', '8186bffffffffff', '81763ffffffffff', + '818a7ffffffffff', '8186fffffffffff', '81707ffffffffff', + '8182bffffffffff', '818f3ffffffffff', '8182fffffffffff', + ] + mpoly = h3.cells_to_h3shape(cells, tight=False) + + assert len(mpoly) == 1 + poly = mpoly[0] + assert len(poly.holes) == 1 + + # Roundtrip fails: h3shape_to_cells recovers only 18/60 cells. + # TODO: assert_cells_roundtrip when h3shape_to_cells handles global polygons. + cells2 = h3.h3shape_to_cells(mpoly, 1) + assert len(cells2) == 18 - Cells along the equator and meridian trigger an error in the C library's - normalizeMultiPolygon. Previously this caused a segfault because the error - code wasn't checked. Now it should raise H3FailedError. + +def test_prime_meridian(): + """Continuous band of cells overlapping the prime meridian. """ cells = [ - '81007ffffffffff', '81017ffffffffff', '81033ffffffffff', - '81047ffffffffff', '81057ffffffffff', '81093ffffffffff', - '81097ffffffffff', '8109bffffffffff', '810cbffffffffff', - '810dbffffffffff', '81167ffffffffff', '81177ffffffffff', - '81187ffffffffff', '81197ffffffffff', '81227ffffffffff', - '8132bffffffffff', '8133bffffffffff', '81383ffffffffff', - '8138bffffffffff', '81397ffffffffff', '8147bffffffffff', - '8158bffffffffff', '81593ffffffffff', '8159bffffffffff', - '815abffffffffff', '815bbffffffffff', '815f3ffffffffff', - '81617ffffffffff', '81657ffffffffff', '8166fffffffffff', - '8168bffffffffff', '8168fffffffffff', '816abffffffffff', - '816afffffffffff', '816c7ffffffffff', '816cfffffffffff', - '816f3ffffffffff', '816f7ffffffffff', '816fbffffffffff', - '81707ffffffffff', '8170bffffffffff', '8170fffffffffff', - '8171bffffffffff', '8172bffffffffff', '8172fffffffffff', - '8173bffffffffff', '81743ffffffffff', '8174bffffffffff', - '81753ffffffffff', '81757ffffffffff', '81763ffffffffff', - '81767ffffffffff', '8177bffffffffff', '81783ffffffffff', - '81787ffffffffff', '8179bffffffffff', '817a3ffffffffff', - '817a7ffffffffff', '817bbffffffffff', '817c3ffffffffff', - '817c7ffffffffff', '817cbffffffffff', '817e3ffffffffff', - '817ebffffffffff', '817efffffffffff', '817f7ffffffffff', - '817fbffffffffff', '81807ffffffffff', '8180bffffffffff', - '8180fffffffffff', '81827ffffffffff', '8182bffffffffff', - '8182fffffffffff', '81853ffffffffff', '81857ffffffffff', - '8186bffffffffff', '8186fffffffffff', '8188bffffffffff', - '8188fffffffffff', '818a7ffffffffff', '818afffffffffff', - '818cbffffffffff', '818cfffffffffff', '818f3ffffffffff', - '81933ffffffffff', '81987ffffffffff', '81997ffffffffff', - '819a7ffffffffff', '819b7ffffffffff', '81ac7ffffffffff', - '81ba3ffffffffff', '81bafffffffffff', '81bb3ffffffffff', - '81c07ffffffffff', '81c17ffffffffff', '81d0bffffffffff', - '81d1bffffffffff', '81db3ffffffffff', '81dbbffffffffff', - '81dcbffffffffff', '81ddbffffffffff', '81e67ffffffffff', - '81eabffffffffff', '81eafffffffffff', '81ed7ffffffffff', - '81eebffffffffff', '81efbffffffffff', '81f17ffffffffff', - '81f2bffffffffff', '81f33ffffffffff', '81f3bffffffffff', + '81efbffffffffff', '81c07ffffffffff', '81d1bffffffffff', + '81097ffffffffff', '8109bffffffffff', '81d0bffffffffff', + '81987ffffffffff', '81017ffffffffff', '81e67ffffffffff', + '81ddbffffffffff', '81ac7ffffffffff', '8158bffffffffff', + '81397ffffffffff', '81593ffffffffff', '81c17ffffffffff', + '81827ffffffffff', '81197ffffffffff', '81eebffffffffff', + '81383ffffffffff', '81dcbffffffffff', '81757ffffffffff', + '81093ffffffffff', '81073ffffffffff', '8159bffffffffff', + '81f17ffffffffff', '81187ffffffffff', '81007ffffffffff', + '81997ffffffffff', '81753ffffffffff', '81033ffffffffff', + '81f2bffffffffff', '8138bffffffffff', ] + mpoly = h3.cells_to_h3shape(cells, tight=False) + assert len(mpoly) == 1 + poly = mpoly[0] + assert len(poly.holes) == 0 + assert len(poly.outer) == 128 + + # Roundtrip fails: h3shape_to_cells raises on this global polygon. + # TODO: assert_cells_roundtrip when h3shape_to_cells handles global polygons. with pytest.raises(H3FailedError): - h3.cells_to_h3shape(cells) + h3.h3shape_to_cells(mpoly, 1) + + +def test_anti_meridian(): + """Continuous band of cells overlapping the anti-meridian.""" + cells = [ + '817ebffffffffff', '8133bffffffffff', '81047ffffffffff', + '81f3bffffffffff', '81dbbffffffffff', '8132bffffffffff', + '810cbffffffffff', '81bb3ffffffffff', '81db3ffffffffff', + '81bafffffffffff', '81177ffffffffff', '817fbffffffffff', + '81ba3ffffffffff', '815abffffffffff', '815bbffffffffff', + '81eafffffffffff', '81ed7ffffffffff', '81057ffffffffff', + '819a7ffffffffff', '81eabffffffffff', '819b7ffffffffff', + '81167ffffffffff', '81227ffffffffff', '8171bffffffffff', + '81237ffffffffff', '810dbffffffffff', '81033ffffffffff', + '81f2bffffffffff', '8147bffffffffff', '81f33ffffffffff', + ] + mpoly = h3.cells_to_h3shape(cells, tight=False) + + assert len(mpoly) == 1 + poly = mpoly[0] + assert len(poly.holes) == 0 + assert len(poly.outer) == 117 + + # Roundtrip fails: h3shape_to_cells raises on this global polygon. + # TODO: assert_cells_roundtrip when h3shape_to_cells handles global polygons. + with pytest.raises(H3FailedError): + h3.h3shape_to_cells(mpoly, 1) + + +def test_issue_482_with_antimeridian(): + """From https://github.com/uber/h3-py/issues/482 + + Test three versions: the full set, removal of antimeridian overlaps, and removal + of one cell by the north pole also causing roundtrip issues. + + Roundtrip works after removal of all troublesome cells. + """ + cells0 = { + '8001fffffffffff', '8005fffffffffff', '8009fffffffffff', '800bfffffffffff', + '8011fffffffffff', '8015fffffffffff', '8017fffffffffff', '801ffffffffffff', + '8021fffffffffff', '8025fffffffffff', '802dfffffffffff', '802ffffffffffff', + '8031fffffffffff', '8033fffffffffff', '803dfffffffffff', '803ffffffffffff', + '8041fffffffffff', '8043fffffffffff', '804bfffffffffff', '804ffffffffffff', + '8053fffffffffff', '8059fffffffffff', '8061fffffffffff', '8063fffffffffff', + '8065fffffffffff', '8069fffffffffff', '806bfffffffffff', '8073fffffffffff', + '8077fffffffffff', + } + + # remove cells that overlap the antimeridian + cells1 = cells0 - {'8005fffffffffff', '8017fffffffffff', '8033fffffffffff'} + + # remove cell that is very close to north pole (maybe touches) + cells2 = cells1 - {'8001fffffffffff'} + + # --- cells0: all 29 cells --- + mpoly = h3.cells_to_h3shape(cells0, tight=False) + assert len(mpoly) == 1 + assert len(mpoly[0].holes) == 0 + assert len(mpoly[0].outer) == 37 + + # Roundtrip fails: recovers ~23/29, and not even a subset of the original. + # TODO: assert_cells_roundtrip when h3shape_to_cells handles global polygons. + cells_out = h3.h3shape_to_cells(mpoly, 0) + assert len(cells_out) < len(cells0) # exact count differs by OS + assert not (set(cells_out) <= cells0) + + # --- cells1: remove antimeridian-crossing cells (26 remaining) --- + mpoly = h3.cells_to_h3shape(cells1, tight=False) + assert len(mpoly) == 1 + assert len(mpoly[0].holes) == 0 + assert len(mpoly[0].outer) == 37 + + # Roundtrip fails: recovers 18/26, and not even a subset of the original. + # TODO: assert_cells_roundtrip when h3shape_to_cells handles global polygons. + cells_out = h3.h3shape_to_cells(mpoly, 0) + assert len(cells_out) == 18 + assert not (set(cells_out) <= cells1) + + # --- cells2: also remove near-pole cell (25 remaining) --- + mpoly = h3.cells_to_h3shape(cells2, tight=False) + assert len(mpoly) == 1 + assert len(mpoly[0].holes) == 0 + assert len(mpoly[0].outer) == 37 + + # Roundtrip succeeds. + assert_cells_roundtrip(list(cells2))