tilefetch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tilefetch/__init__.py +110 -0
- tilefetch/__main__.py +5 -0
- tilefetch/_version.py +1 -0
- tilefetch/api.py +592 -0
- tilefetch/cli.py +419 -0
- tilefetch/download.py +334 -0
- tilefetch/georef.py +291 -0
- tilefetch/mosaic.py +191 -0
- tilefetch/pmtiles.py +657 -0
- tilefetch/providers.py +137 -0
- tilefetch/py.typed +0 -0
- tilefetch/sources.py +546 -0
- tilefetch/storage.py +338 -0
- tilefetch/tiles.py +469 -0
- tilefetch-0.1.0.dist-info/METADATA +264 -0
- tilefetch-0.1.0.dist-info/RECORD +20 -0
- tilefetch-0.1.0.dist-info/WHEEL +5 -0
- tilefetch-0.1.0.dist-info/entry_points.txt +2 -0
- tilefetch-0.1.0.dist-info/licenses/LICENSE +21 -0
- tilefetch-0.1.0.dist-info/top_level.txt +1 -0
tilefetch/tiles.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Web Mercator tile math: XYZ/TMS addressing, quadkeys, bounds and coverage.
|
|
2
|
+
|
|
3
|
+
All tiles use the "Google" XYZ convention (origin at the top-left, y grows
|
|
4
|
+
southwards) unless a function says otherwise. Longitudes and latitudes are in
|
|
5
|
+
degrees (EPSG:4326); projected coordinates are EPSG:3857 metres.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any, Iterable, Iterator, NamedTuple, Sequence, Union
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"EARTH_RADIUS",
|
|
17
|
+
"ORIGIN_SHIFT",
|
|
18
|
+
"MAX_LATITUDE",
|
|
19
|
+
"Tile",
|
|
20
|
+
"BBox",
|
|
21
|
+
"lonlat_to_mercator",
|
|
22
|
+
"mercator_to_lonlat",
|
|
23
|
+
"lonlat_to_tile",
|
|
24
|
+
"tile_bounds_mercator",
|
|
25
|
+
"tile_bounds_lonlat",
|
|
26
|
+
"resolution",
|
|
27
|
+
"flip_y",
|
|
28
|
+
"tile_to_quadkey",
|
|
29
|
+
"quadkey_to_tile",
|
|
30
|
+
"parse_bbox",
|
|
31
|
+
"parse_zoom",
|
|
32
|
+
"bbox_to_mercator",
|
|
33
|
+
"tiles_for_bbox",
|
|
34
|
+
"tiles_for_geometry",
|
|
35
|
+
"tiles_for_polygons",
|
|
36
|
+
"load_geometry",
|
|
37
|
+
"geometry_bbox",
|
|
38
|
+
"tile_count",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
EARTH_RADIUS = 6378137.0
|
|
42
|
+
#: Half the width of the Web Mercator world, in metres.
|
|
43
|
+
ORIGIN_SHIFT = math.pi * EARTH_RADIUS # 20037508.342789244
|
|
44
|
+
#: Latitude at which the Web Mercator square ends.
|
|
45
|
+
MAX_LATITUDE = math.degrees(2.0 * math.atan(math.exp(math.pi)) - math.pi / 2.0) # 85.0511287798
|
|
46
|
+
#: Highest zoom level accepted anywhere in the package.
|
|
47
|
+
MAX_ZOOM = 30
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Tile(NamedTuple):
|
|
51
|
+
"""An XYZ tile address (top-left origin)."""
|
|
52
|
+
|
|
53
|
+
z: int
|
|
54
|
+
x: int
|
|
55
|
+
y: int
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def tms_y(self) -> int:
|
|
59
|
+
"""Row in the TMS scheme (bottom-left origin), as used by MBTiles."""
|
|
60
|
+
return flip_y(self.z, self.y)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def quadkey(self) -> str:
|
|
64
|
+
return tile_to_quadkey(self)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BBox(NamedTuple):
|
|
68
|
+
"""A bounding box ``(minx, miny, maxx, maxy)``."""
|
|
69
|
+
|
|
70
|
+
minx: float
|
|
71
|
+
miny: float
|
|
72
|
+
maxx: float
|
|
73
|
+
maxy: float
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
BBoxLike = Union[BBox, Sequence[float], str]
|
|
77
|
+
ZoomLike = Union[int, str, Iterable[int]]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check_zoom(z: int) -> None:
|
|
81
|
+
if not isinstance(z, int) or isinstance(z, bool) or z < 0 or z > MAX_ZOOM:
|
|
82
|
+
raise ValueError(f"zoom must be an integer between 0 and {MAX_ZOOM}, got {z!r}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _clamp_lat(lat: float) -> float:
|
|
86
|
+
return max(-MAX_LATITUDE, min(MAX_LATITUDE, lat))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def lonlat_to_mercator(lon: float, lat: float) -> tuple[float, float]:
|
|
90
|
+
"""Project longitude/latitude (degrees) to EPSG:3857 metres.
|
|
91
|
+
|
|
92
|
+
Latitudes beyond +/-85.0511 degrees are clamped to the edge of the square.
|
|
93
|
+
"""
|
|
94
|
+
lat = _clamp_lat(lat)
|
|
95
|
+
x = math.radians(lon) * EARTH_RADIUS
|
|
96
|
+
y = math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0)) * EARTH_RADIUS
|
|
97
|
+
return x, y
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def mercator_to_lonlat(x: float, y: float) -> tuple[float, float]:
|
|
101
|
+
"""Inverse of :func:`lonlat_to_mercator`."""
|
|
102
|
+
lon = math.degrees(x / EARTH_RADIUS)
|
|
103
|
+
lat = math.degrees(2.0 * math.atan(math.exp(y / EARTH_RADIUS)) - math.pi / 2.0)
|
|
104
|
+
return lon, lat
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolution(z: int, tile_size: int = 256) -> float:
|
|
108
|
+
"""Ground resolution in metres per pixel (EPSG:3857 units) at zoom ``z``."""
|
|
109
|
+
_check_zoom(z)
|
|
110
|
+
return 2.0 * ORIGIN_SHIFT / (tile_size * (1 << z))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def lonlat_to_tile(lon: float, lat: float, z: int) -> Tile:
|
|
114
|
+
"""Return the tile containing a point. Points on the east/south edge of
|
|
115
|
+
the world map to the last tile instead of falling outside the grid."""
|
|
116
|
+
_check_zoom(z)
|
|
117
|
+
n = 1 << z
|
|
118
|
+
x, y = lonlat_to_mercator(lon, lat)
|
|
119
|
+
fx = (x + ORIGIN_SHIFT) / (2.0 * ORIGIN_SHIFT) * n
|
|
120
|
+
fy = (ORIGIN_SHIFT - y) / (2.0 * ORIGIN_SHIFT) * n
|
|
121
|
+
tx = min(n - 1, max(0, int(math.floor(fx))))
|
|
122
|
+
ty = min(n - 1, max(0, int(math.floor(fy))))
|
|
123
|
+
return Tile(z, tx, ty)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def tile_bounds_mercator(tile: Tile | tuple[int, int, int]) -> BBox:
|
|
127
|
+
"""EPSG:3857 bounds ``(minx, miny, maxx, maxy)`` of an XYZ tile."""
|
|
128
|
+
z, x, y = tile
|
|
129
|
+
_check_zoom(z)
|
|
130
|
+
size = 2.0 * ORIGIN_SHIFT / (1 << z)
|
|
131
|
+
minx = -ORIGIN_SHIFT + x * size
|
|
132
|
+
maxy = ORIGIN_SHIFT - y * size
|
|
133
|
+
return BBox(minx, maxy - size, minx + size, maxy)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def tile_bounds_lonlat(tile: Tile | tuple[int, int, int]) -> BBox:
|
|
137
|
+
"""Longitude/latitude bounds ``(west, south, east, north)`` of an XYZ tile."""
|
|
138
|
+
b = tile_bounds_mercator(tile)
|
|
139
|
+
w, s = mercator_to_lonlat(b.minx, b.miny)
|
|
140
|
+
e, n = mercator_to_lonlat(b.maxx, b.maxy)
|
|
141
|
+
return BBox(w, s, e, n)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def flip_y(z: int, y: int) -> int:
|
|
145
|
+
"""Convert a row between the XYZ and TMS schemes (the operation is its own inverse)."""
|
|
146
|
+
_check_zoom(z)
|
|
147
|
+
return (1 << z) - 1 - y
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def tile_to_quadkey(tile: Tile | tuple[int, int, int]) -> str:
|
|
151
|
+
"""Bing-style quadkey of an XYZ tile (empty string at zoom 0)."""
|
|
152
|
+
z, x, y = tile
|
|
153
|
+
_check_zoom(z)
|
|
154
|
+
digits = []
|
|
155
|
+
for i in range(z, 0, -1):
|
|
156
|
+
mask = 1 << (i - 1)
|
|
157
|
+
d = 0
|
|
158
|
+
if x & mask:
|
|
159
|
+
d += 1
|
|
160
|
+
if y & mask:
|
|
161
|
+
d += 2
|
|
162
|
+
digits.append(str(d))
|
|
163
|
+
return "".join(digits)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def quadkey_to_tile(quadkey: str) -> Tile:
|
|
167
|
+
"""Inverse of :func:`tile_to_quadkey`."""
|
|
168
|
+
x = y = 0
|
|
169
|
+
z = len(quadkey)
|
|
170
|
+
_check_zoom(z)
|
|
171
|
+
for i, ch in enumerate(quadkey):
|
|
172
|
+
mask = 1 << (z - i - 1)
|
|
173
|
+
if ch == "1":
|
|
174
|
+
x |= mask
|
|
175
|
+
elif ch == "2":
|
|
176
|
+
y |= mask
|
|
177
|
+
elif ch == "3":
|
|
178
|
+
x |= mask
|
|
179
|
+
y |= mask
|
|
180
|
+
elif ch != "0":
|
|
181
|
+
raise ValueError(f"invalid quadkey digit {ch!r} in {quadkey!r}")
|
|
182
|
+
return Tile(z, x, y)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_bbox(bbox: BBoxLike) -> BBox:
|
|
186
|
+
"""Normalise a bbox given as a sequence or a ``"minx,miny,maxx,maxy"`` string.
|
|
187
|
+
|
|
188
|
+
For lon/lat boxes ``minx > maxx`` is allowed and means the box crosses the
|
|
189
|
+
antimeridian. ``miny`` must not exceed ``maxy``.
|
|
190
|
+
"""
|
|
191
|
+
if isinstance(bbox, str):
|
|
192
|
+
parts = [p for p in bbox.replace(" ", ",").split(",") if p]
|
|
193
|
+
values = [float(p) for p in parts]
|
|
194
|
+
else:
|
|
195
|
+
values = [float(v) for v in bbox]
|
|
196
|
+
if len(values) != 4:
|
|
197
|
+
raise ValueError(f"bbox needs 4 numbers (minx, miny, maxx, maxy), got {len(values)}")
|
|
198
|
+
if any(math.isnan(v) or math.isinf(v) for v in values):
|
|
199
|
+
raise ValueError("bbox values must be finite")
|
|
200
|
+
b = BBox(*values)
|
|
201
|
+
if b.miny > b.maxy:
|
|
202
|
+
raise ValueError(f"bbox miny ({b.miny}) is greater than maxy ({b.maxy})")
|
|
203
|
+
return b
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_zoom(zoom: ZoomLike) -> list[int]:
|
|
207
|
+
"""Parse ``12``, ``"12"``, ``"12-15"``, ``"10,12,14"`` or ``[12, 13]`` into a sorted list."""
|
|
208
|
+
levels: set[int] = set()
|
|
209
|
+
if isinstance(zoom, bool):
|
|
210
|
+
raise ValueError("zoom must be an int, a range string or a list of ints")
|
|
211
|
+
if isinstance(zoom, int):
|
|
212
|
+
levels.add(zoom)
|
|
213
|
+
elif isinstance(zoom, str):
|
|
214
|
+
for part in zoom.split(","):
|
|
215
|
+
part = part.strip()
|
|
216
|
+
if not part:
|
|
217
|
+
continue
|
|
218
|
+
if "-" in part:
|
|
219
|
+
a, b = part.split("-", 1)
|
|
220
|
+
lo, hi = int(a), int(b)
|
|
221
|
+
if lo > hi:
|
|
222
|
+
raise ValueError(f"empty zoom range {part!r}")
|
|
223
|
+
levels.update(range(lo, hi + 1))
|
|
224
|
+
else:
|
|
225
|
+
levels.add(int(part))
|
|
226
|
+
else:
|
|
227
|
+
levels.update(int(z) for z in zoom)
|
|
228
|
+
if not levels:
|
|
229
|
+
raise ValueError("no zoom levels given")
|
|
230
|
+
for z in levels:
|
|
231
|
+
_check_zoom(z)
|
|
232
|
+
return sorted(levels)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _split_antimeridian(b: BBox) -> list[BBox]:
|
|
236
|
+
if b.minx <= b.maxx:
|
|
237
|
+
return [b]
|
|
238
|
+
return [BBox(b.minx, b.miny, 180.0, b.maxy), BBox(-180.0, b.miny, b.maxx, b.maxy)]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def bbox_to_mercator(bbox: BBoxLike) -> BBox:
|
|
242
|
+
"""Project a lon/lat bbox to EPSG:3857 (latitudes clamped to the Mercator square)."""
|
|
243
|
+
b = parse_bbox(bbox)
|
|
244
|
+
if b.minx > b.maxx:
|
|
245
|
+
raise ValueError("bbox crosses the antimeridian; split it before projecting")
|
|
246
|
+
x0, y0 = lonlat_to_mercator(b.minx, b.miny)
|
|
247
|
+
x1, y1 = lonlat_to_mercator(b.maxx, b.maxy)
|
|
248
|
+
return BBox(x0, y0, x1, y1)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _tile_range(b: BBox, z: int, crs: str) -> tuple[int, int, int, int]:
|
|
252
|
+
"""Inclusive (xmin, ymin, xmax, ymax) tile indices covering a bbox at zoom z."""
|
|
253
|
+
n = 1 << z
|
|
254
|
+
if crs == "EPSG:3857":
|
|
255
|
+
mb = b
|
|
256
|
+
else:
|
|
257
|
+
x0, y0 = lonlat_to_mercator(b.minx, b.miny)
|
|
258
|
+
x1, y1 = lonlat_to_mercator(b.maxx, b.maxy)
|
|
259
|
+
mb = BBox(x0, y0, x1, y1)
|
|
260
|
+
size = 2.0 * ORIGIN_SHIFT / n
|
|
261
|
+
eps = 1e-9 * size
|
|
262
|
+
|
|
263
|
+
def col(x: float, upper: bool) -> int:
|
|
264
|
+
f = (x + ORIGIN_SHIFT) / size
|
|
265
|
+
# A box edge exactly on a tile edge must not pull in the neighbour tile.
|
|
266
|
+
i = math.ceil(f - eps) - 1 if upper else math.floor(f + eps)
|
|
267
|
+
return min(n - 1, max(0, int(i)))
|
|
268
|
+
|
|
269
|
+
def row(y: float, upper: bool) -> int:
|
|
270
|
+
f = (ORIGIN_SHIFT - y) / size
|
|
271
|
+
i = math.ceil(f - eps) - 1 if upper else math.floor(f + eps)
|
|
272
|
+
return min(n - 1, max(0, int(i)))
|
|
273
|
+
|
|
274
|
+
xmin, xmax = col(mb.minx, False), col(mb.maxx, True)
|
|
275
|
+
ymin, ymax = row(mb.maxy, False), row(mb.miny, True)
|
|
276
|
+
# Degenerate (zero-width) boxes still cover one tile.
|
|
277
|
+
xmax = max(xmax, xmin)
|
|
278
|
+
ymax = max(ymax, ymin)
|
|
279
|
+
return xmin, ymin, xmax, ymax
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _normalise_crs(crs: str | int) -> str:
|
|
283
|
+
s = str(crs).upper().replace("EPSG:", "")
|
|
284
|
+
if s in ("4326", "CRS84", "OGC:CRS84"):
|
|
285
|
+
return "EPSG:4326"
|
|
286
|
+
if s in ("3857", "900913", "3785", "102100"):
|
|
287
|
+
return "EPSG:3857"
|
|
288
|
+
raise ValueError(f"unsupported CRS {crs!r}; use EPSG:4326 or EPSG:3857")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _iter_bbox_tiles(bbox: BBoxLike, zooms: list[int], crs: str) -> Iterator[Tile]:
|
|
292
|
+
b = parse_bbox(bbox)
|
|
293
|
+
boxes = _split_antimeridian(b) if crs == "EPSG:4326" else [b]
|
|
294
|
+
if crs == "EPSG:3857" and b.minx > b.maxx:
|
|
295
|
+
raise ValueError("EPSG:3857 bbox must have minx <= maxx")
|
|
296
|
+
for z in zooms:
|
|
297
|
+
seen: set[tuple[int, int]] = set()
|
|
298
|
+
for part in boxes:
|
|
299
|
+
xmin, ymin, xmax, ymax = _tile_range(part, z, crs)
|
|
300
|
+
for x in range(xmin, xmax + 1):
|
|
301
|
+
for y in range(ymin, ymax + 1):
|
|
302
|
+
if (x, y) not in seen:
|
|
303
|
+
seen.add((x, y))
|
|
304
|
+
yield Tile(z, x, y)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def tile_count(bbox: BBoxLike, zoom: ZoomLike, crs: str | int = "EPSG:4326") -> int:
|
|
308
|
+
"""Number of tiles covering a bbox, without materialising the list."""
|
|
309
|
+
zooms = parse_zoom(zoom)
|
|
310
|
+
c = _normalise_crs(crs)
|
|
311
|
+
b = parse_bbox(bbox)
|
|
312
|
+
total = 0
|
|
313
|
+
for z in zooms:
|
|
314
|
+
cols: set[int] = set()
|
|
315
|
+
rows = 0
|
|
316
|
+
for part in _split_antimeridian(b) if c == "EPSG:4326" else [b]:
|
|
317
|
+
xmin, ymin, xmax, ymax = _tile_range(part, z, c)
|
|
318
|
+
cols.update(range(xmin, xmax + 1))
|
|
319
|
+
rows = ymax - ymin + 1
|
|
320
|
+
total += len(cols) * rows
|
|
321
|
+
return total
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def tiles_for_bbox(bbox: BBoxLike, zoom: ZoomLike, crs: str | int = "EPSG:4326") -> list[Tile]:
|
|
325
|
+
"""All XYZ tiles intersecting a bbox at the given zoom level(s).
|
|
326
|
+
|
|
327
|
+
``bbox`` is ``(west, south, east, north)`` in degrees by default, or EPSG:3857
|
|
328
|
+
metres with ``crs="EPSG:3857"``. Lon/lat boxes with ``west > east`` cross
|
|
329
|
+
the antimeridian. Tiles are ordered by zoom, then column, then row.
|
|
330
|
+
"""
|
|
331
|
+
return list(_iter_bbox_tiles(bbox, parse_zoom(zoom), _normalise_crs(crs)))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# GeoJSON polygons
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
Ring = list[tuple[float, float]]
|
|
339
|
+
Polygon = list[Ring] # exterior ring followed by holes
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def load_geometry(geojson: Any) -> list[Polygon]:
|
|
343
|
+
"""Read Polygon/MultiPolygon geometry from a GeoJSON object, string or file path.
|
|
344
|
+
|
|
345
|
+
Accepts a geometry, a Feature, a FeatureCollection or a path to a
|
|
346
|
+
``.geojson`` file. Coordinates are longitude/latitude (RFC 7946).
|
|
347
|
+
Returns a list of polygons, each a list of rings of ``(lon, lat)``.
|
|
348
|
+
"""
|
|
349
|
+
obj = geojson
|
|
350
|
+
if isinstance(obj, (str, os.PathLike)):
|
|
351
|
+
text = str(obj)
|
|
352
|
+
if text.lstrip().startswith("{"):
|
|
353
|
+
obj = json.loads(text)
|
|
354
|
+
else:
|
|
355
|
+
with open(obj, encoding="utf-8") as fh:
|
|
356
|
+
obj = json.load(fh)
|
|
357
|
+
if not isinstance(obj, dict):
|
|
358
|
+
raise ValueError("GeoJSON must be a dict, a JSON string or a file path")
|
|
359
|
+
polygons: list[Polygon] = []
|
|
360
|
+
|
|
361
|
+
def walk(o: dict[str, Any]) -> None:
|
|
362
|
+
t = o.get("type")
|
|
363
|
+
if t == "FeatureCollection":
|
|
364
|
+
for f in o.get("features", []):
|
|
365
|
+
walk(f)
|
|
366
|
+
elif t == "Feature":
|
|
367
|
+
if o.get("geometry"):
|
|
368
|
+
walk(o["geometry"])
|
|
369
|
+
elif t == "GeometryCollection":
|
|
370
|
+
for g in o.get("geometries", []):
|
|
371
|
+
walk(g)
|
|
372
|
+
elif t == "Polygon":
|
|
373
|
+
polygons.append(
|
|
374
|
+
[[(float(p[0]), float(p[1])) for p in ring] for ring in o["coordinates"]]
|
|
375
|
+
)
|
|
376
|
+
elif t == "MultiPolygon":
|
|
377
|
+
for poly in o["coordinates"]:
|
|
378
|
+
polygons.append([[(float(p[0]), float(p[1])) for p in ring] for ring in poly])
|
|
379
|
+
else:
|
|
380
|
+
raise ValueError(f"unsupported GeoJSON type {t!r}; expected (Multi)Polygon")
|
|
381
|
+
|
|
382
|
+
walk(obj)
|
|
383
|
+
if not polygons:
|
|
384
|
+
raise ValueError("GeoJSON contains no polygons")
|
|
385
|
+
return polygons
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def geometry_bbox(polygons: list[Polygon]) -> BBox:
|
|
389
|
+
"""Lon/lat bounds of the exterior rings of a list of polygons."""
|
|
390
|
+
xs = [p[0] for poly in polygons for p in poly[0]]
|
|
391
|
+
ys = [p[1] for poly in polygons for p in poly[0]]
|
|
392
|
+
return BBox(min(xs), min(ys), max(xs), max(ys))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _point_in_ring(x: float, y: float, ring: Ring) -> bool:
|
|
396
|
+
inside = False
|
|
397
|
+
n = len(ring)
|
|
398
|
+
j = n - 1
|
|
399
|
+
for i in range(n):
|
|
400
|
+
xi, yi = ring[i]
|
|
401
|
+
xj, yj = ring[j]
|
|
402
|
+
if (yi > y) != (yj > y):
|
|
403
|
+
xc = (xj - xi) * (y - yi) / (yj - yi) + xi
|
|
404
|
+
if x < xc:
|
|
405
|
+
inside = not inside
|
|
406
|
+
j = i
|
|
407
|
+
return inside
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _point_in_polygon(x: float, y: float, poly: Polygon) -> bool:
|
|
411
|
+
if not _point_in_ring(x, y, poly[0]):
|
|
412
|
+
return False
|
|
413
|
+
return not any(_point_in_ring(x, y, hole) for hole in poly[1:])
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _segment_hits_rect(a: tuple[float, float], b: tuple[float, float], r: BBox) -> bool:
|
|
417
|
+
"""Liang-Barsky clip test: does segment ab touch rectangle r?"""
|
|
418
|
+
x0, y0 = a
|
|
419
|
+
dx, dy = b[0] - x0, b[1] - y0
|
|
420
|
+
t0, t1 = 0.0, 1.0
|
|
421
|
+
for p, q in ((-dx, x0 - r.minx), (dx, r.maxx - x0), (-dy, y0 - r.miny), (dy, r.maxy - y0)):
|
|
422
|
+
if p == 0:
|
|
423
|
+
if q < 0:
|
|
424
|
+
return False
|
|
425
|
+
else:
|
|
426
|
+
t = q / p
|
|
427
|
+
if p < 0:
|
|
428
|
+
t0 = max(t0, t)
|
|
429
|
+
else:
|
|
430
|
+
t1 = min(t1, t)
|
|
431
|
+
if t0 > t1:
|
|
432
|
+
return False
|
|
433
|
+
return True
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _poly_hits_rect(poly: Polygon, r: BBox) -> bool:
|
|
437
|
+
for ring in poly:
|
|
438
|
+
for i in range(len(ring) - 1):
|
|
439
|
+
if _segment_hits_rect(ring[i], ring[i + 1], r):
|
|
440
|
+
return True
|
|
441
|
+
cx, cy = (r.minx + r.maxx) / 2.0, (r.miny + r.maxy) / 2.0
|
|
442
|
+
# Rectangle entirely inside the polygon (and not inside a hole).
|
|
443
|
+
return _point_in_polygon(cx, cy, poly)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def tiles_for_geometry(geojson: Any, zoom: ZoomLike) -> list[Tile]:
|
|
447
|
+
"""Tiles that intersect GeoJSON (Multi)Polygon geometry.
|
|
448
|
+
|
|
449
|
+
Polygon edges are treated as straight lines in Web Mercator, which is the
|
|
450
|
+
space the tiles live in. Candidate tiles from the bbox are tested exactly
|
|
451
|
+
against every polygon (including holes).
|
|
452
|
+
"""
|
|
453
|
+
return tiles_for_polygons(load_geometry(geojson), zoom)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def tiles_for_polygons(polygons: list[Polygon], zoom: ZoomLike) -> list[Tile]:
|
|
457
|
+
"""Like :func:`tiles_for_geometry` for polygons already read by :func:`load_geometry`."""
|
|
458
|
+
merc = [
|
|
459
|
+
[[lonlat_to_mercator(lon, lat) for lon, lat in ring] for ring in poly] for poly in polygons
|
|
460
|
+
]
|
|
461
|
+
bounds = geometry_bbox(polygons)
|
|
462
|
+
out: list[Tile] = []
|
|
463
|
+
for t in _iter_bbox_tiles(bounds, parse_zoom(zoom), "EPSG:4326"):
|
|
464
|
+
r = tile_bounds_mercator(t)
|
|
465
|
+
for poly in merc:
|
|
466
|
+
if _poly_hits_rect(poly, r):
|
|
467
|
+
out.append(t)
|
|
468
|
+
break
|
|
469
|
+
return out
|