bboxkit 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.
bboxkit/bbox.py ADDED
@@ -0,0 +1,1029 @@
1
+ """The :class:`BBox` value type."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import math
7
+ from typing import (
8
+ Any,
9
+ Callable,
10
+ Dict,
11
+ Iterable,
12
+ Iterator,
13
+ List,
14
+ Optional,
15
+ Sequence,
16
+ Tuple,
17
+ Union,
18
+ )
19
+
20
+ from . import geohash as _geohash
21
+ from . import mgrs as _mgrs
22
+ from . import pluscode as _pluscode
23
+ from . import tiles as _tiles
24
+ from ._geo import (
25
+ MERCATOR_MAX_LAT,
26
+ Arc,
27
+ arc_to_lons,
28
+ covering_arc,
29
+ lon_arc,
30
+ meridian_distance,
31
+ metres_to_dlat,
32
+ metres_to_dlon,
33
+ parallel_radius,
34
+ rectangle_area_m2,
35
+ snap_arc_edges,
36
+ unwrapped_to_lons,
37
+ wrap_delta,
38
+ wrap_lon,
39
+ )
40
+ from .crs import (
41
+ WEB_MERCATOR,
42
+ WGS84,
43
+ CRSLike,
44
+ _from_lonlat_fn,
45
+ _pyproj_transformer,
46
+ _to_lonlat_fn,
47
+ is_builtin,
48
+ is_geographic,
49
+ lonlat_to_mercator,
50
+ mercator_to_lonlat,
51
+ normalize_crs,
52
+ utm_crs,
53
+ utm_zone,
54
+ )
55
+ from .tiles import Tile
56
+
57
+ __all__ = ["BBox", "union_all", "Number"]
58
+
59
+ Number = Union[int, float]
60
+ PointLike = Sequence[float]
61
+
62
+ _EPS = 1e-12
63
+ #: Upper bound on the number of boxes or tiles a single call may create.
64
+ DEFAULT_MAX_ITEMS = 1_000_000
65
+
66
+
67
+ @functools.lru_cache(maxsize=512)
68
+ def _crs_info(crs: CRSLike) -> Tuple[str, bool]:
69
+ c = normalize_crs(crs)
70
+ return (c, is_geographic(c))
71
+
72
+
73
+ def _num(value: float, precision: Optional[int] = None) -> str:
74
+ """Compact, exact (round-trippable) decimal text for a coordinate."""
75
+ if precision is not None:
76
+ value = round(value, precision)
77
+ if value == 0:
78
+ return "0"
79
+ text = repr(float(value))
80
+ if "e" in text or "E" in text:
81
+ text = f"{value:.17f}".rstrip("0").rstrip(".")
82
+ if text.endswith(".0"):
83
+ text = text[:-2]
84
+ return text
85
+
86
+
87
+ def _coerce(name: str, value: Any) -> float:
88
+ if isinstance(value, (str, bytes, bool)) or value is None:
89
+ raise TypeError(f"{name} must be a number, got {value!r}")
90
+ try:
91
+ f = float(value)
92
+ except (TypeError, ValueError) as exc:
93
+ raise TypeError(f"{name} must be a number, got {value!r}") from exc
94
+ if not math.isfinite(f):
95
+ raise ValueError(f"{name} must be finite, got {value!r}")
96
+ return f + 0.0 # turns -0.0 into 0.0
97
+
98
+
99
+ class BBox:
100
+ """An immutable, hashable bounding box ``(west, south, east, north, crs)``.
101
+
102
+ For geographic CRSs (longitude/latitude degrees, ``EPSG:4326`` by default)
103
+ ``west > east`` means the box crosses the antimeridian, exactly as in
104
+ RFC 7946. ``BBox(-180, s, 180, n)`` spans every longitude. Coordinates are
105
+ validated on construction and the object cannot be modified afterwards;
106
+ use :meth:`replace` to derive a new box.
107
+
108
+ ``==`` and ``hash`` are exact (so boxes work as dict keys and in sets);
109
+ use :meth:`equals` for a tolerance-based comparison.
110
+ """
111
+
112
+ __slots__ = ("west", "south", "east", "north", "crs", "_geographic")
113
+
114
+ west: float
115
+ south: float
116
+ east: float
117
+ north: float
118
+ crs: str
119
+ _geographic: bool
120
+
121
+ def __init__(
122
+ self,
123
+ west: Number,
124
+ south: Number,
125
+ east: Number,
126
+ north: Number,
127
+ crs: CRSLike = WGS84,
128
+ ) -> None:
129
+ c, geographic = _crs_info(crs)
130
+ w = _coerce("west", west)
131
+ s = _coerce("south", south)
132
+ e = _coerce("east", east)
133
+ n = _coerce("north", north)
134
+ if s > n:
135
+ raise ValueError(f"south ({s}) is greater than north ({n}); check the coordinate order")
136
+ if geographic:
137
+ if not (-90.0 <= s <= 90.0 and -90.0 <= n <= 90.0):
138
+ raise ValueError(f"latitudes must be within [-90, 90], got south={s}, north={n}")
139
+ if not (-180.0 <= w <= 180.0 and -180.0 <= e <= 180.0):
140
+ raise ValueError(
141
+ f"longitudes must be within [-180, 180], got west={w}, east={e}"
142
+ " (use BBox.wrap() for unwrapped longitudes)"
143
+ )
144
+ # Canonical spelling of the antimeridian edges.
145
+ if w == 180.0 and e < 180.0:
146
+ w = -180.0
147
+ if e == -180.0 and w > -180.0:
148
+ e = 180.0
149
+ elif w > e:
150
+ raise ValueError(
151
+ f"west ({w}) is greater than east ({e}); only geographic boxes may cross"
152
+ " the antimeridian"
153
+ )
154
+ for name, value in (
155
+ ("west", w),
156
+ ("south", s),
157
+ ("east", e),
158
+ ("north", n),
159
+ ("crs", c),
160
+ ("_geographic", geographic),
161
+ ):
162
+ object.__setattr__(self, name, value)
163
+
164
+ # ------------------------------------------------------------------
165
+ # Value-type plumbing
166
+ # ------------------------------------------------------------------
167
+
168
+ def __setattr__(self, name: str, value: Any) -> None:
169
+ raise AttributeError("BBox is immutable; use replace()")
170
+
171
+ def __delattr__(self, name: str) -> None:
172
+ raise AttributeError("BBox is immutable")
173
+
174
+ def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]:
175
+ return (BBox, (self.west, self.south, self.east, self.north, self.crs))
176
+
177
+ def _key(self) -> Tuple[float, float, float, float, str]:
178
+ return (self.west, self.south, self.east, self.north, self.crs)
179
+
180
+ def __eq__(self, other: object) -> bool:
181
+ if not isinstance(other, BBox):
182
+ return NotImplemented
183
+ return self._key() == other._key()
184
+
185
+ def __hash__(self) -> int:
186
+ return hash(self._key())
187
+
188
+ def __repr__(self) -> str:
189
+ suffix = "" if self.crs == WGS84 else f", crs={self.crs!r}"
190
+ return f"BBox({self.west!r}, {self.south!r}, {self.east!r}, {self.north!r}{suffix})"
191
+
192
+ def __str__(self) -> str:
193
+ return self.to_string()
194
+
195
+ def __iter__(self) -> Iterator[float]:
196
+ """Iterate ``west, south, east, north`` (so ``w, s, e, n = box`` works)."""
197
+ return iter((self.west, self.south, self.east, self.north))
198
+
199
+ def __round__(self, ndigits: Optional[int] = None) -> BBox:
200
+ return self.round(ndigits)
201
+
202
+ @property
203
+ def __geo_interface__(self) -> Dict[str, Any]:
204
+ """GeoJSON geometry, so ``shapely.geometry.shape(box)`` just works."""
205
+ return self.to_geojson()
206
+
207
+ def replace(self, **changes: Any) -> BBox:
208
+ """Return a copy with some of ``west/south/east/north/crs`` replaced."""
209
+ values: Dict[str, Any] = {
210
+ "west": self.west,
211
+ "south": self.south,
212
+ "east": self.east,
213
+ "north": self.north,
214
+ "crs": self.crs,
215
+ }
216
+ unknown = set(changes) - set(values)
217
+ if unknown:
218
+ raise TypeError(f"unknown field(s): {', '.join(sorted(unknown))}")
219
+ values.update(changes)
220
+ return BBox(**values)
221
+
222
+ def equals(self, other: BBox, tol: float = 1e-9) -> bool:
223
+ """Equality within ``tol`` (CRS units); longitudes compare modulo 360."""
224
+ if not isinstance(other, BBox):
225
+ return False
226
+ if self.crs != other.crs:
227
+ return False
228
+ if abs(self.south - other.south) > tol or abs(self.north - other.north) > tol:
229
+ return False
230
+ if self._geographic:
231
+ if self.is_full_longitude and other.is_full_longitude:
232
+ return True
233
+ return (
234
+ abs(self.width - other.width) <= 2 * tol
235
+ and abs(wrap_delta(self.west - other.west)) <= tol
236
+ and abs(wrap_delta(self.east - other.east)) <= tol
237
+ )
238
+ return abs(self.west - other.west) <= tol and abs(self.east - other.east) <= tol
239
+
240
+ # ------------------------------------------------------------------
241
+ # Construction helpers
242
+ # ------------------------------------------------------------------
243
+
244
+ @classmethod
245
+ def parse(cls, value: Any, **kwargs: Any) -> BBox:
246
+ """Parse any supported representation; see :func:`bboxkit.parse`."""
247
+ from .parsing import parse
248
+
249
+ return parse(value, **kwargs)
250
+
251
+ @classmethod
252
+ def from_bounds(cls, bounds: Sequence[Number], crs: CRSLike = WGS84) -> BBox:
253
+ """From a ``(minx, miny, maxx, maxy)`` tuple, e.g. Shapely ``geom.bounds``."""
254
+ if len(bounds) != 4:
255
+ raise ValueError(f"expected 4 numbers, got {len(bounds)}")
256
+ return cls(bounds[0], bounds[1], bounds[2], bounds[3], crs=crs)
257
+
258
+ @classmethod
259
+ def wrap(
260
+ cls, west: Number, south: Number, east: Number, north: Number, crs: CRSLike = WGS84
261
+ ) -> BBox:
262
+ """Build a geographic box from unwrapped longitudes (e.g. ``170 .. 190``).
263
+
264
+ ``east - west >= 360`` gives the full longitude range; otherwise both
265
+ longitudes are wrapped into [-180, 180], which may produce a box that
266
+ crosses the antimeridian.
267
+ """
268
+ w, e = float(west), float(east)
269
+ if e < w:
270
+ raise ValueError("wrap() expects west <= east in unwrapped longitudes")
271
+ lo, hi = unwrapped_to_lons(w, e)
272
+ return cls(lo, south, hi, north, crs=crs)
273
+
274
+ @classmethod
275
+ def world(cls) -> BBox:
276
+ """The whole globe, ``(-180, -90, 180, 90)``."""
277
+ return cls(-180.0, -90.0, 180.0, 90.0)
278
+
279
+ @classmethod
280
+ def from_point(cls, lon: Number, lat: Number, crs: CRSLike = WGS84) -> BBox:
281
+ """A zero-size box at a point; combine with :meth:`buffer`."""
282
+ return cls(lon, lat, lon, lat, crs=crs)
283
+
284
+ # ------------------------------------------------------------------
285
+ # Basic properties
286
+ # ------------------------------------------------------------------
287
+
288
+ @property
289
+ def is_geographic(self) -> bool:
290
+ return self._geographic
291
+
292
+ @property
293
+ def crosses_antimeridian(self) -> bool:
294
+ return self._geographic and self.west > self.east
295
+
296
+ @property
297
+ def is_full_longitude(self) -> bool:
298
+ return self._geographic and self.west == -180.0 and self.east == 180.0
299
+
300
+ @property
301
+ def bounds(self) -> Tuple[float, float, float, float]:
302
+ """``(west, south, east, north)`` as a plain tuple."""
303
+ return (self.west, self.south, self.east, self.north)
304
+
305
+ @property
306
+ def width(self) -> float:
307
+ """East-west extent in CRS units (degrees across the antimeridian if needed)."""
308
+ if self._geographic:
309
+ return self._arc()[1]
310
+ return self.east - self.west
311
+
312
+ @property
313
+ def height(self) -> float:
314
+ return self.north - self.south
315
+
316
+ @property
317
+ def center(self) -> Tuple[float, float]:
318
+ """Midpoint ``(x, y)``; the longitude midpoint follows the antimeridian."""
319
+ if self._geographic:
320
+ start, width = self._arc()
321
+ mid = start + width / 2.0
322
+ lon = mid if mid <= 180.0 else mid - 360.0
323
+ return (lon, (self.south + self.north) / 2.0)
324
+ return ((self.west + self.east) / 2.0, (self.south + self.north) / 2.0)
325
+
326
+ @property
327
+ def width_m(self) -> float:
328
+ """Ground width along the central parallel, in metres (CRS units if projected)."""
329
+ if self._geographic:
330
+ return parallel_radius(self.center[1]) * math.radians(self.width)
331
+ return self.width
332
+
333
+ @property
334
+ def height_m(self) -> float:
335
+ """Ground height along a meridian, in metres (CRS units if projected)."""
336
+ if self._geographic:
337
+ return meridian_distance(self.north) - meridian_distance(self.south)
338
+ return self.height
339
+
340
+ def _arc(self) -> Arc:
341
+ return lon_arc(self.west, self.east)
342
+
343
+ def _same_crs(self, other: BBox) -> None:
344
+ if not isinstance(other, BBox):
345
+ raise TypeError(f"expected a BBox, got {type(other).__name__}")
346
+ if other.crs != self.crs:
347
+ raise ValueError(
348
+ f"CRS mismatch: {self.crs} vs {other.crs}; convert one with to_crs() first"
349
+ )
350
+
351
+ def _from_lon_arc(self, arc: Arc, south: float, north: float) -> BBox:
352
+ w, e = arc_to_lons(arc)
353
+ return BBox(w, south, e, north, crs=self.crs)
354
+
355
+ def parts(self) -> Tuple[BBox, ...]:
356
+ """Split at the antimeridian: one box, or two if it crosses."""
357
+ if not self.crosses_antimeridian:
358
+ return (self,)
359
+ return (
360
+ BBox(self.west, self.south, 180.0, self.north, crs=self.crs),
361
+ BBox(-180.0, self.south, self.east, self.north, crs=self.crs),
362
+ )
363
+
364
+ split_antimeridian = parts
365
+
366
+ # ------------------------------------------------------------------
367
+ # Predicates and set operations
368
+ # ------------------------------------------------------------------
369
+
370
+ def contains(self, other: Union[BBox, PointLike]) -> bool:
371
+ """True if ``other`` (a box or an ``(x, y)`` point) lies inside, edges included."""
372
+ if not isinstance(other, BBox):
373
+ x, y = float(other[0]), float(other[1])
374
+ if not self.south - _EPS <= y <= self.north + _EPS:
375
+ return False
376
+ if self._geographic:
377
+ start, width = self._arc()
378
+ return (x - start) % 360.0 <= width + _EPS or width >= 360.0
379
+ return self.west - _EPS <= x <= self.east + _EPS
380
+ self._same_crs(other)
381
+ if other.south < self.south - _EPS or other.north > self.north + _EPS:
382
+ return False
383
+ if not self._geographic:
384
+ return self.west - _EPS <= other.west and other.east <= self.east + _EPS
385
+ s_start, s_width = self._arc()
386
+ o_start, o_width = other._arc()
387
+ if s_width >= 360.0:
388
+ return True
389
+ if o_width > s_width + _EPS:
390
+ return False
391
+ offset = (o_start - s_start) % 360.0
392
+ if offset > 360.0 - _EPS:
393
+ offset = 0.0
394
+ return offset + o_width <= s_width + 1e-9
395
+
396
+ def __contains__(self, item: Union[BBox, PointLike]) -> bool:
397
+ return self.contains(item)
398
+
399
+ def intersects(self, other: BBox) -> bool:
400
+ """True if the boxes share at least one point (touching counts)."""
401
+ self._same_crs(other)
402
+ if other.south > self.north or other.north < self.south:
403
+ return False
404
+ if not self._geographic:
405
+ return other.west <= self.east and other.east >= self.west
406
+ return bool(self._lon_overlaps(other))
407
+
408
+ def _lon_overlaps(self, other: BBox) -> List[Tuple[float, float]]:
409
+ s_start, s_width = self._arc()
410
+ o_start, o_width = other._arc()
411
+ if s_width >= 360.0:
412
+ return [(o_start, o_start + o_width)]
413
+ if o_width >= 360.0:
414
+ return [(s_start, s_start + s_width)]
415
+ lo_s, hi_s = s_start, s_start + s_width
416
+ out: List[Tuple[float, float]] = []
417
+ for k in (-1, 0, 1):
418
+ lo_o = o_start + 360.0 * k
419
+ hi_o = lo_o + o_width
420
+ lo, hi = max(lo_s, lo_o), min(hi_s, hi_o)
421
+ if lo <= hi and (lo, hi) not in out:
422
+ out.append((lo, hi))
423
+ return out
424
+
425
+ def intersection_parts(self, other: BBox) -> List[BBox]:
426
+ """All pieces of the intersection (two when wide boxes overlap at both ends)."""
427
+ self._same_crs(other)
428
+ s, n = max(self.south, other.south), min(self.north, other.north)
429
+ if s > n:
430
+ return []
431
+ if not self._geographic:
432
+ w, e = max(self.west, other.west), min(self.east, other.east)
433
+ return [BBox(w, s, e, n, crs=self.crs)] if w <= e else []
434
+ return [
435
+ self._from_lon_arc((wrap_lon(lo), hi - lo), s, n)
436
+ for lo, hi in self._lon_overlaps(other)
437
+ ]
438
+
439
+ def intersection(self, other: BBox) -> Optional[BBox]:
440
+ """The common box, or ``None`` if the boxes do not meet.
441
+
442
+ Raises ``ValueError`` in the rare case where the overlap is two separate
443
+ pieces (see :meth:`intersection_parts`).
444
+ """
445
+ pieces = self.intersection_parts(other)
446
+ if not pieces:
447
+ return None
448
+ if len(pieces) > 1:
449
+ raise ValueError(
450
+ "the intersection consists of two separate boxes; use intersection_parts()"
451
+ )
452
+ return pieces[0]
453
+
454
+ def __and__(self, other: BBox) -> Optional[BBox]:
455
+ return self.intersection(other)
456
+
457
+ def union(self, *others: BBox) -> BBox:
458
+ """Smallest box containing this box and ``others`` (antimeridian-aware)."""
459
+ return union_all((self,) + others)
460
+
461
+ def __or__(self, other: BBox) -> BBox:
462
+ return self.union(other)
463
+
464
+ # ------------------------------------------------------------------
465
+ # Measurements and transformations
466
+ # ------------------------------------------------------------------
467
+
468
+ def area_km2(self) -> float:
469
+ """Area in square kilometres.
470
+
471
+ Geographic boxes use the exact area of a lat/lon rectangle on the WGS 84
472
+ ellipsoid. Web Mercator boxes are converted to lon/lat first. Other
473
+ projected boxes return ``width * height / 1e6`` in CRS units (metres for
474
+ UTM, i.e. grid area).
475
+ """
476
+ if self._geographic:
477
+ return rectangle_area_m2(self.width, self.south, self.north) / 1e6
478
+ if self.crs == WEB_MERCATOR:
479
+ return self.to_crs(WGS84).area_km2()
480
+ return self.width * self.height / 1e6
481
+
482
+ def buffer(self, distance: float) -> BBox:
483
+ """Grow (or shrink, if negative) the box by ``distance`` metres on every side.
484
+
485
+ Geographic boxes use the local radii of curvature of WGS 84 so that every
486
+ point within ``distance`` of the box ends up inside it; the longitude
487
+ margin is taken at the most poleward latitude. Boxes reaching a pole get
488
+ the full longitude range. Projected boxes are buffered in CRS units.
489
+ """
490
+ d = float(distance)
491
+ if not math.isfinite(d):
492
+ raise ValueError("distance must be finite")
493
+ if not self._geographic:
494
+ w, s, e, n = self.west - d, self.south - d, self.east + d, self.north + d
495
+ if w > e or s > n:
496
+ raise ValueError("negative buffer collapses the box")
497
+ return BBox(w, s, e, n, crs=self.crs)
498
+ if d >= 0:
499
+ s = max(-90.0, self.south - metres_to_dlat(d, self.south))
500
+ n = min(90.0, self.north + metres_to_dlat(d, self.north))
501
+ pole_lat = max(abs(s), abs(n))
502
+ if pole_lat >= 90.0 - 1e-9 or self.is_full_longitude:
503
+ return BBox(-180.0, s, 180.0, n, crs=self.crs)
504
+ dlon = metres_to_dlon(d, pole_lat)
505
+ start, width = self._arc()
506
+ if width + 2 * dlon >= 360.0:
507
+ return BBox(-180.0, s, 180.0, n, crs=self.crs)
508
+ return self._from_lon_arc((wrap_lon(start - dlon), width + 2 * dlon), s, n)
509
+ m = -d
510
+ s = self.south + metres_to_dlat(m, self.south)
511
+ n = self.north - metres_to_dlat(m, self.north)
512
+ if s > n:
513
+ raise ValueError("negative buffer collapses the box")
514
+ equator_lat = 0.0 if s <= 0.0 <= n else min(abs(s), abs(n))
515
+ dlon = metres_to_dlon(m, equator_lat)
516
+ start, width = self._arc()
517
+ if 2 * dlon > width:
518
+ raise ValueError("negative buffer collapses the box")
519
+ return self._from_lon_arc((wrap_lon(start + dlon), width - 2 * dlon), s, n)
520
+
521
+ def round(self, ndigits: Optional[int] = None) -> BBox:
522
+ """Round every coordinate (``round(box, 4)`` works too)."""
523
+ vals = [float(round(v, ndigits)) for v in self]
524
+ return BBox(vals[0], vals[1], vals[2], vals[3], crs=self.crs)
525
+
526
+ def snap(self, step: float, outward: bool = True) -> BBox:
527
+ """Snap the edges to multiples of ``step`` (outward keeps the box covered)."""
528
+ if not step > 0:
529
+ raise ValueError("step must be positive")
530
+ lo_fn: Callable[[float], float]
531
+ hi_fn: Callable[[float], float]
532
+ if outward:
533
+
534
+ def lo_fn(v: float) -> float:
535
+ return math.floor(v / step + 1e-9) * step
536
+
537
+ def hi_fn(v: float) -> float:
538
+ return math.ceil(v / step - 1e-9) * step
539
+ else:
540
+
541
+ def lo_fn(v: float) -> float:
542
+ return round(v / step) * step
543
+
544
+ hi_fn = lo_fn
545
+
546
+ def clean(v: float) -> float:
547
+ return float(round(v, 12))
548
+
549
+ s, n = clean(lo_fn(self.south)), clean(hi_fn(self.north))
550
+ if not self._geographic:
551
+ return BBox(clean(lo_fn(self.west)), s, clean(hi_fn(self.east)), n, crs=self.crs)
552
+ s, n = max(-90.0, s), min(90.0, n)
553
+ if s > n:
554
+ s = n = clean(lo_fn((self.south + self.north) / 2.0))
555
+ start, width = self._arc()
556
+ lo, hi = clean(lo_fn(start)), clean(hi_fn(start + width))
557
+ if hi < lo:
558
+ hi = lo
559
+ w, e = (clean(v) for v in unwrapped_to_lons(lo, hi))
560
+ return BBox(max(-180.0, w), s, min(180.0, e), n, crs=self.crs)
561
+
562
+ def expand_to_aspect(self, ratio: float, mode: str = "mercator") -> BBox:
563
+ """Grow the box (never shrink) about its centre to ``width / height == ratio``.
564
+
565
+ ``mode`` picks the space the ratio is measured in: ``"mercator"`` (what a
566
+ web map shows), ``"ground"`` (metres on the ground) or ``"degrees"``.
567
+ Projected boxes always use CRS units.
568
+ """
569
+ if not ratio > 0 or not math.isfinite(ratio):
570
+ raise ValueError("ratio must be a positive number")
571
+ if not self._geographic:
572
+ w, s, e, n = _expand(self.west, self.south, self.east, self.north, ratio)
573
+ return BBox(w, s, e, n, crs=self.crs)
574
+ start, width = self._arc()
575
+ if mode == "degrees":
576
+ w, s, e, n = _expand(start, self.south, start + width, self.north, ratio)
577
+ return BBox.wrap(w, max(-90.0, s), e, min(90.0, n), crs=self.crs)
578
+ if mode == "mercator":
579
+ x0, y0 = lonlat_to_mercator(start, self.south)
580
+ x1, y1 = lonlat_to_mercator(start + width, self.north)
581
+ mx0, my0, mx1, my1 = _expand(x0, y0, x1, y1, ratio)
582
+ w, s = mercator_to_lonlat(mx0, my0)
583
+ e, n = mercator_to_lonlat(mx1, my1)
584
+ # Latitudes beyond the Mercator limit are kept, never cut.
585
+ s, n = min(s, self.south), max(n, self.north)
586
+ return BBox.wrap(w, s, e, n, crs=self.crs)
587
+ if mode == "ground":
588
+ w_m, h_m = self.width_m, self.height_m
589
+ if h_m <= 0 and w_m <= 0:
590
+ return self
591
+ if h_m <= 0 or w_m / h_m > ratio:
592
+ grow = (w_m / ratio - h_m) / 2.0
593
+ s = max(-90.0, self.south - metres_to_dlat(grow, self.south))
594
+ n = min(90.0, self.north + metres_to_dlat(grow, self.north))
595
+ return BBox(self.west, s, self.east, n, crs=self.crs)
596
+ grow_deg = metres_to_dlon((h_m * ratio - w_m) / 2.0, self.center[1])
597
+ return BBox.wrap(
598
+ start - grow_deg, self.south, start + width + grow_deg, self.north, crs=self.crs
599
+ )
600
+ raise ValueError("mode must be 'mercator', 'ground' or 'degrees'")
601
+
602
+ # ------------------------------------------------------------------
603
+ # Grids and tiles
604
+ # ------------------------------------------------------------------
605
+
606
+ def grid(self, nx: int, ny: int) -> List[BBox]:
607
+ """Split into ``nx`` columns by ``ny`` rows.
608
+
609
+ Cells are returned row by row from the north-west corner, like map
610
+ tiles. Cells may cross the antimeridian when the box does.
611
+ """
612
+ if int(nx) != nx or int(ny) != ny or nx < 1 or ny < 1:
613
+ raise ValueError("nx and ny must be positive integers")
614
+ nx, ny = int(nx), int(ny)
615
+ if nx * ny > DEFAULT_MAX_ITEMS:
616
+ raise ValueError(f"grid of {nx * ny} cells exceeds {DEFAULT_MAX_ITEMS}")
617
+ start = self._arc()[0] if self._geographic else self.west
618
+ return self._cells(start, self.width / nx, nx, self.height / ny, ny, even=True)
619
+
620
+ def _cells(
621
+ self, x0: float, dx: float, nx: int, dy: float, ny: int, even: bool = False
622
+ ) -> List[BBox]:
623
+ x_end = x0 + self.width
624
+ east_u = self._unwrapped_east() if self._geographic else self.east
625
+
626
+ def x_at(i: int) -> float:
627
+ if even: # interpolate between the exact edges: 29 .. 29.2 halves at 29.1
628
+ return (self.west * (nx - i) + east_u * i) / nx
629
+ return min(x_end, x0 + i * dx)
630
+
631
+ def y_at(j: int) -> float:
632
+ if even:
633
+ return (self.north * (ny - j) + self.south * j) / ny
634
+ return max(self.south, self.north - j * dy)
635
+
636
+ # Column edges; the outer ones are the box's own (exact) edges.
637
+ edges = [self.west]
638
+ for i in range(1, nx):
639
+ x = x_at(i)
640
+ edges.append(x - 360.0 if self._geographic and x > 180.0 else x)
641
+ edges.append(self.east)
642
+ rows = [self.north] + [y_at(j) for j in range(1, ny)] + [self.south]
643
+ out: List[BBox] = []
644
+ for j in range(ny):
645
+ n, s = rows[j], rows[j + 1]
646
+ for i in range(nx):
647
+ w, e = edges[i], edges[i + 1]
648
+ if self._geographic and e == -180.0 and i + 1 < nx:
649
+ e = 180.0 # an inner edge on the antimeridian closes the cell at +180
650
+ out.append(BBox(w, s, e, n, crs=self.crs))
651
+ return out
652
+
653
+ def split(
654
+ self,
655
+ cell: float,
656
+ cell_y: Optional[float] = None,
657
+ max_cells: int = DEFAULT_MAX_ITEMS,
658
+ ) -> List[BBox]:
659
+ """Split into cells of ``cell`` x ``cell_y`` metres (square if ``cell_y`` is None).
660
+
661
+ Geographic boxes get a regular lon/lat grid whose cells measure the
662
+ requested size at the box's central latitude. The last column and row
663
+ are clipped to the box. Order is row by row from the north-west.
664
+ Projected boxes use CRS units.
665
+ """
666
+ cx = float(cell)
667
+ cy = cx if cell_y is None else float(cell_y)
668
+ if not (cx > 0 and cy > 0):
669
+ raise ValueError("cell size must be positive")
670
+ if self._geographic:
671
+ lat = self.center[1]
672
+ dx = metres_to_dlon(cx, lat)
673
+ dy = metres_to_dlat(cy, lat)
674
+ else:
675
+ dx, dy = cx, cy
676
+ nx = max(1, math.ceil(self.width / dx - 1e-9)) if self.width > 0 else 1
677
+ ny = max(1, math.ceil(self.height / dy - 1e-9)) if self.height > 0 else 1
678
+ if nx * ny > max_cells:
679
+ raise ValueError(f"split would create {nx * ny} cells (max_cells={max_cells})")
680
+ start = self._arc()[0] if self._geographic else self.west
681
+ return self._cells(start, dx, nx, dy, ny)
682
+
683
+ def _wgs84(self) -> BBox:
684
+ return self if self.crs == WGS84 else self.to_crs(WGS84)
685
+
686
+ def tile_count(self, zoom: int) -> int:
687
+ """Number of XYZ tiles at ``zoom`` overlapping the box."""
688
+ b = self._wgs84()
689
+ return sum(
690
+ (x1 - x0 + 1) * (y1 - y0 + 1)
691
+ for x0, x1, y0, y1 in _tiles.tile_ranges(b.west, b.south, b.east, b.north, zoom)
692
+ )
693
+
694
+ def tiles(self, zoom: int, max_tiles: int = DEFAULT_MAX_ITEMS) -> List[Tile]:
695
+ """XYZ tiles at ``zoom`` overlapping the box (latitudes clamped to +-85.05)."""
696
+ count = self.tile_count(zoom)
697
+ if count > max_tiles:
698
+ raise ValueError(f"{count} tiles at zoom {zoom} exceed max_tiles={max_tiles}")
699
+ b = self._wgs84()
700
+ return list(_tiles.tiles_covering(b.west, b.south, b.east, b.north, zoom))
701
+
702
+ def iter_tiles(self, zoom: int) -> Iterator[Tile]:
703
+ """Lazily yield the tiles of :meth:`tiles` without a size limit."""
704
+ b = self._wgs84()
705
+ return _tiles.tiles_covering(b.west, b.south, b.east, b.north, zoom)
706
+
707
+ # ------------------------------------------------------------------
708
+ # Reprojection
709
+ # ------------------------------------------------------------------
710
+
711
+ def _edge_points(self, densify: int) -> List[Tuple[float, float]]:
712
+ steps = max(1, int(densify) + 1)
713
+ x0 = self._arc()[0] if self._geographic else self.west
714
+ x1 = x0 + self.width
715
+ pts: List[Tuple[float, float]] = []
716
+ for i in range(steps + 1):
717
+ t = i / steps
718
+ x = x0 + (x1 - x0) * t
719
+ y = self.south + (self.north - self.south) * t
720
+ pts.extend(((x, self.south), (x, self.north), (x0, y), (x1, y)))
721
+ return pts
722
+
723
+ def to_crs(self, crs: CRSLike, densify: int = 21) -> BBox:
724
+ """Reproject by transforming ``densify`` points along every edge.
725
+
726
+ WGS 84, Web Mercator and UTM are built in; any other CRS needs pyproj.
727
+ A box crossing the antimeridian keeps contiguous x values in Web
728
+ Mercator (east may exceed 20037508.34 m) and becomes a crossing box
729
+ again when converted back to longitude/latitude.
730
+ """
731
+ dst = normalize_crs(crs)
732
+ if dst == self.crs:
733
+ return self
734
+ if not (is_builtin(self.crs) and is_builtin(dst)):
735
+ t = _pyproj_transformer(self.crs, dst)
736
+ w, s, e, n = t.transform_bounds(
737
+ self.west, self.south, self.east, self.north, densify_pts=densify
738
+ )
739
+ if _crs_info(dst)[1]:
740
+ return BBox(max(-180.0, w), max(-90.0, s), min(180.0, e), min(90.0, n), crs=dst)
741
+ return BBox(w, s, e, n, crs=dst)
742
+ to_ll = _to_lonlat_fn(self.crs)
743
+ lonlat = [to_ll(x, y) for x, y in self._edge_points(densify)]
744
+ if dst == WGS84:
745
+ lons = [p[0] for p in lonlat]
746
+ lats = [p[1] for p in lonlat]
747
+ w, e = unwrapped_to_lons(min(lons), max(lons))
748
+ return BBox(w, max(-90.0, min(lats)), e, min(90.0, max(lats)), crs=dst)
749
+ from_ll = _from_lonlat_fn(dst)
750
+ xy = [from_ll(lon, lat) for lon, lat in lonlat]
751
+ xs = [p[0] for p in xy]
752
+ ys = [p[1] for p in xy]
753
+ return BBox(min(xs), min(ys), max(xs), max(ys), crs=dst)
754
+
755
+ def to_utm(self, zone: Optional[int] = None, south: Optional[bool] = None) -> BBox:
756
+ """Reproject to WGS 84 / UTM (zone and hemisphere default to the centre's)."""
757
+ b = self._wgs84()
758
+ lon, lat = b.center
759
+ z = utm_zone(lon, lat) if zone is None else zone
760
+ hemi_south = (lat < 0) if south is None else south
761
+ return self.to_crs(utm_crs(z, hemi_south))
762
+
763
+ # ------------------------------------------------------------------
764
+ # Formatting
765
+ # ------------------------------------------------------------------
766
+
767
+ def to_tuple(self) -> Tuple[float, float, float, float]:
768
+ """``(west, south, east, north)``."""
769
+ return self.bounds
770
+
771
+ def to_dict(self) -> Dict[str, Any]:
772
+ return {
773
+ "west": self.west,
774
+ "south": self.south,
775
+ "east": self.east,
776
+ "north": self.north,
777
+ "crs": self.crs,
778
+ }
779
+
780
+ def to_string(
781
+ self, order: str = "wsen", sep: str = ",", precision: Optional[int] = None
782
+ ) -> str:
783
+ """Four numbers in ``order`` (``"wsen"`` or ``"swne"``)."""
784
+ key = order.lower()
785
+ if key not in ("wsen", "swne"):
786
+ raise ValueError("order must be 'wsen' or 'swne'")
787
+ vals = self.bounds if key == "wsen" else (self.south, self.west, self.north, self.east)
788
+ return sep.join(_num(v, precision) for v in vals)
789
+
790
+ def to_wsen(self, precision: Optional[int] = None) -> str:
791
+ """``"west,south,east,north"`` (GeoJSON, OSM API, Shapely, STAC order)."""
792
+ return self.to_string("wsen", precision=precision)
793
+
794
+ def to_swne(self, precision: Optional[int] = None) -> str:
795
+ """``"south,west,north,east"`` (Overpass, Leaflet, bboxfinder URL order)."""
796
+ return self.to_string("swne", precision=precision)
797
+
798
+ def to_bboxfinder(self, precision: Optional[int] = None) -> str:
799
+ """bboxfinder.com link (its URL fragment is ``south,west,north,east``)."""
800
+ return "http://bboxfinder.com/#" + self._wgs84().to_swne(precision)
801
+
802
+ def to_overpass(self, setting: bool = False, precision: Optional[int] = None) -> str:
803
+ """Overpass QL bbox filter ``(s,w,n,e)`` or global ``[bbox:s,w,n,e]`` setting."""
804
+ text = self._wgs84().to_swne(precision)
805
+ return f"[bbox:{text}]" if setting else f"({text})"
806
+
807
+ def to_osm_url(self, viewport: Tuple[int, int] = (1024, 768)) -> str:
808
+ """openstreetmap.org link whose ``#map=zoom/lat/lon`` view fits the box."""
809
+ b = self._wgs84()
810
+ vw, vh = viewport
811
+ if vw <= 0 or vh <= 0:
812
+ raise ValueError("viewport must be positive")
813
+ start, width = b._arc()
814
+ x0, y0 = lonlat_to_mercator(start, b.south)
815
+ x1, y1 = lonlat_to_mercator(start + width, b.north)
816
+ world = 2 * math.pi * 6378137.0
817
+ frac_w = max((x1 - x0) / world, 1e-12)
818
+ frac_h = max((y1 - y0) / world, 1e-12)
819
+ zoom = math.floor(math.log2(min(vw / (256 * frac_w), vh / (256 * frac_h))))
820
+ zoom = max(0, min(19, zoom))
821
+ lon, _ = mercator_to_lonlat((x0 + x1) / 2.0, 0.0)
822
+ _, lat = mercator_to_lonlat(0.0, (y0 + y1) / 2.0)
823
+ lon = wrap_lon(lon) if lon >= 180.0 else lon
824
+ return f"https://www.openstreetmap.org/#map={zoom}/{lat:.5f}/{lon:.5f}"
825
+
826
+ def _ring(self, w: float, e: float, precision: Optional[int]) -> str:
827
+ s, n = self.south, self.north
828
+ pts = ((w, s), (e, s), (e, n), (w, n), (w, s))
829
+ return "(" + ", ".join(f"{_num(x, precision)} {_num(y, precision)}" for x, y in pts) + ")"
830
+
831
+ def to_wkt(self, precision: Optional[int] = None) -> str:
832
+ """WKT ``POLYGON`` (``MULTIPOLYGON`` split at the antimeridian if crossing)."""
833
+ parts = self.parts()
834
+ if len(parts) == 1:
835
+ return f"POLYGON ({self._ring(self.west, self.east, precision)})"
836
+ rings = ", ".join(f"({p._ring(p.west, p.east, precision)})" for p in parts)
837
+ return f"MULTIPOLYGON ({rings})"
838
+
839
+ def to_wkt_envelope(self, precision: Optional[int] = None) -> str:
840
+ """CQL / Solr ``ENVELOPE(minX, maxX, maxY, minY)``."""
841
+ p = precision
842
+ return (
843
+ f"ENVELOPE({_num(self.west, p)}, {_num(self.east, p)}, "
844
+ f"{_num(self.north, p)}, {_num(self.south, p)})"
845
+ )
846
+
847
+ def _require_plain(self, what: str) -> None:
848
+ if self.crosses_antimeridian:
849
+ raise ValueError(f"{what} cannot express an antimeridian-crossing box; use parts()")
850
+
851
+ def _epsg(self) -> Optional[int]:
852
+ if self.crs.startswith("EPSG:") and self.crs[5:].isdigit():
853
+ return int(self.crs[5:])
854
+ return None
855
+
856
+ def to_postgis(self, precision: Optional[int] = None) -> str:
857
+ """PostGIS ``ST_MakeEnvelope(xmin, ymin, xmax, ymax, srid)``."""
858
+ self._require_plain("ST_MakeEnvelope")
859
+ srid = self._epsg()
860
+ nums = ", ".join(_num(v, precision) for v in self)
861
+ return f"ST_MakeEnvelope({nums}, {srid if srid is not None else 0})"
862
+
863
+ def to_geojson(
864
+ self, feature: bool = False, properties: Optional[Dict[str, Any]] = None
865
+ ) -> Dict[str, Any]:
866
+ """GeoJSON geometry (or Feature) with an RFC 7946 ``bbox`` member.
867
+
868
+ Crossing boxes become a ``MultiPolygon`` split at the antimeridian, as
869
+ RFC 7946 section 3.1.9 recommends; the ``bbox`` keeps ``west > east``.
870
+ Non-WGS 84 boxes are reprojected first (GeoJSON is always WGS 84).
871
+ """
872
+ b = self._wgs84()
873
+
874
+ def ring(p: BBox) -> List[List[float]]:
875
+ w, s, e, n = p.bounds
876
+ return [[w, s], [e, s], [e, n], [w, n], [w, s]]
877
+
878
+ parts = b.parts()
879
+ geom: Dict[str, Any]
880
+ if len(parts) == 1:
881
+ geom = {"type": "Polygon", "coordinates": [ring(b)]}
882
+ else:
883
+ geom = {"type": "MultiPolygon", "coordinates": [[ring(p)] for p in parts]}
884
+ geom["bbox"] = list(b.bounds)
885
+ if not feature:
886
+ return geom
887
+ bbox = geom.pop("bbox")
888
+ return {
889
+ "type": "Feature",
890
+ "bbox": bbox,
891
+ "properties": dict(properties or {}),
892
+ "geometry": geom,
893
+ }
894
+
895
+ def to_shapely_bounds(self) -> Tuple[float, float, float, float]:
896
+ """``(minx, miny, maxx, maxy)`` as Shapely's ``geom.bounds`` returns it."""
897
+ self._require_plain("a Shapely bounds tuple")
898
+ return self.bounds
899
+
900
+ def to_gdal_te(self, with_srs: bool = False, precision: Optional[int] = None) -> str:
901
+ """gdalwarp ``-te xmin ymin xmax ymax`` (optionally with ``-te_srs``)."""
902
+ self._require_plain("gdalwarp -te")
903
+ text = "-te " + " ".join(_num(v, precision) for v in self)
904
+ return text + f" -te_srs {self.crs}" if with_srs else text
905
+
906
+ def to_gdal_projwin(self, with_srs: bool = False, precision: Optional[int] = None) -> str:
907
+ """gdal_translate ``-projwin ulx uly lrx lry`` (optionally with ``-projwin_srs``)."""
908
+ self._require_plain("gdal_translate -projwin")
909
+ vals = (self.west, self.north, self.east, self.south)
910
+ text = "-projwin " + " ".join(_num(v, precision) for v in vals)
911
+ return text + f" -projwin_srs {self.crs}" if with_srs else text
912
+
913
+ def _unwrapped_east(self) -> float:
914
+ return self.east + 360.0 if self.crosses_antimeridian else self.east
915
+
916
+ def to_leaflet(self) -> List[List[float]]:
917
+ """Leaflet ``LatLngBounds`` array ``[[south, west], [north, east]]``.
918
+
919
+ Crossing boxes use ``east + 360`` so the map shows one contiguous box.
920
+ """
921
+ b = self._wgs84()
922
+ return [[b.south, b.west], [b.north, b._unwrapped_east()]]
923
+
924
+ def to_mapbox(self) -> List[List[float]]:
925
+ """Mapbox / MapLibre ``LngLatBounds`` array ``[[west, south], [east, north]]``."""
926
+ b = self._wgs84()
927
+ return [[b.west, b.south], [b._unwrapped_east(), b.north]]
928
+
929
+ def _deepest(self, levels: Iterable[Any], cell_of: Callable[[Any], BBox]) -> Any:
930
+ best = None
931
+ for level in levels:
932
+ cell = cell_of(level)
933
+ if not cell.contains(self):
934
+ break
935
+ best = level
936
+ return best
937
+
938
+ def to_tile(self, max_zoom: int = _tiles.MAX_ZOOM) -> Tile:
939
+ """Deepest XYZ tile containing the whole box (latitudes clamped to +-85.05)."""
940
+ b = self._wgs84()
941
+ clamped = b.replace(
942
+ south=max(-MERCATOR_MAX_LAT, min(MERCATOR_MAX_LAT, b.south)),
943
+ north=max(-MERCATOR_MAX_LAT, min(MERCATOR_MAX_LAT, b.north)),
944
+ )
945
+ lon, lat = clamped.center
946
+
947
+ def cell(z: int) -> BBox:
948
+ return BBox(*_tiles.lonlat_to_tile(lon, lat, z).bounds)
949
+
950
+ z = clamped._deepest(range(max_zoom + 1), cell)
951
+ if z is None: # only if latitudes reach the clamp exactly; z0 is the world
952
+ z = 0
953
+ return _tiles.lonlat_to_tile(lon, lat, z)
954
+
955
+ def to_quadkey(self, max_zoom: int = _tiles.MAX_ZOOM) -> str:
956
+ """Quadkey of :meth:`to_tile` (``""`` when only the zoom-0 tile fits)."""
957
+ return self.to_tile(max_zoom).quadkey
958
+
959
+ def to_geohash(self, max_precision: int = _geohash.MAX_PRECISION) -> str:
960
+ """Longest geohash whose cell contains the whole box."""
961
+ b = self._wgs84()
962
+ lon, lat = b.center
963
+ p = b._deepest(
964
+ range(1, max_precision + 1),
965
+ lambda k: BBox(*_geohash.decode_bounds(_geohash.encode(lon, lat, k))),
966
+ )
967
+ if p is None:
968
+ raise ValueError(
969
+ "the box is larger than any geohash cell (straddles a 45 x 45 deg cell edge)"
970
+ )
971
+ return _geohash.encode(lon, lat, p)
972
+
973
+ def to_pluscode(self) -> str:
974
+ """Longest Plus Code whose area contains the whole box."""
975
+ b = self._wgs84()
976
+ lon, lat = b.center
977
+ length = b._deepest(
978
+ _pluscode.VALID_LENGTHS,
979
+ lambda k: BBox(*_pluscode.decode_bounds(_pluscode.encode(lon, lat, k))),
980
+ )
981
+ if length is None:
982
+ raise ValueError("the box is larger than any Plus Code area (20 x 20 deg)")
983
+ return _pluscode.encode(lon, lat, length)
984
+
985
+ def to_mgrs(self) -> str:
986
+ """Finest MGRS square (100 km down to 1 m) containing the whole box."""
987
+ b = self._wgs84()
988
+ lon, lat = b.center
989
+ p = b._deepest(range(0, 6), lambda k: BBox(*_mgrs.square_bounds(_mgrs.encode(lon, lat, k))))
990
+ if p is None:
991
+ raise ValueError("the box does not fit in a single MGRS 100 km square")
992
+ return _mgrs.encode(lon, lat, p)
993
+
994
+ def format(self, fmt: str, **kwargs: Any) -> str:
995
+ """Format by name (see :data:`bboxkit.FORMATS`); lists and dicts become JSON."""
996
+ from .formats import format_bbox
997
+
998
+ return format_bbox(self, fmt, **kwargs)
999
+
1000
+
1001
+ def union_all(boxes: Iterable[BBox]) -> BBox:
1002
+ """Smallest box containing every box (RFC 7946 minimal-longitude-gap rule)."""
1003
+ items = list(boxes)
1004
+ if not items:
1005
+ raise ValueError("union_all() needs at least one box")
1006
+ first = items[0]
1007
+ for b in items[1:]:
1008
+ first._same_crs(b)
1009
+ s = min(b.south for b in items)
1010
+ n = max(b.north for b in items)
1011
+ if not first.is_geographic:
1012
+ return BBox(min(b.west for b in items), s, max(b.east for b in items), n, crs=first.crs)
1013
+ arc = covering_arc(b._arc() for b in items)
1014
+ w, e = snap_arc_edges(arc, [b.west for b in items], [b.east for b in items])
1015
+ return BBox(w, s, e, n, crs=first.crs)
1016
+
1017
+
1018
+ def _expand(
1019
+ w: float, s: float, e: float, n: float, ratio: float
1020
+ ) -> Tuple[float, float, float, float]:
1021
+ """Grow ``(w, s, e, n)`` about its centre to ``width / height == ratio``."""
1022
+ width, height = e - w, n - s
1023
+ if height == 0 and width == 0:
1024
+ return (w, s, e, n)
1025
+ if height == 0 or width / height > ratio:
1026
+ grow = (width / ratio - height) / 2.0
1027
+ return (w, s - grow, e, n + grow)
1028
+ grow = (height * ratio - width) / 2.0
1029
+ return (w - grow, s, e + grow, n)