Metadata-Version: 2.4
Name: bboxkit
Version: 0.1.0
Summary: Zero-dependency bounding boxes: parse and format every bbox convention (WKT, GeoJSON, Overpass, GDAL, tiles, geohash, Plus Codes, MGRS), antimeridian-aware geometry, ellipsoidal area and reprojection.
Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/YusufEminoglu/bboxkit_sdk
Project-URL: Repository, https://github.com/YusufEminoglu/bboxkit_sdk
Project-URL: Issues, https://github.com/YusufEminoglu/bboxkit_sdk/issues
Project-URL: Changelog, https://github.com/YusufEminoglu/bboxkit_sdk/blob/main/CHANGELOG.md
Keywords: bbox,bounding-box,gis,geojson,wkt,overpass,openstreetmap,gdal,xyz-tiles,quadkey,geohash,plus-codes,mgrs,utm,web-mercator,antimeridian
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: pyproj
Requires-Dist: pyproj>=3.4; extra == "pyproj"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Dynamic: license-file

# bboxkit

**Bounding boxes, without the guesswork.** Parse a bbox from whatever tool handed it to you,
convert it to whatever the next tool wants, and do correct geometry on it, including boxes
that cross the antimeridian. Pure Python, zero dependencies, fully typed.

```bash
pip install bboxkit            # zero dependencies
pip install "bboxkit[pyproj]"  # optional: reproject to any CRS
```

```python
>>> from bboxkit import BBox, parse
>>> box = parse("29.0,40.9,29.2,41.1", order="wsen")   # Istanbul
>>> box.to_overpass()
'(40.9,29,41.1,29.2)'
>>> box.to_gdal_te()
'-te 29 40.9 29.2 41.1'
>>> round(box.area_km2(), 3)                          # exact on the WGS 84 ellipsoid
373.741
```

Every tool agrees that a bounding box has four numbers. Almost nothing else is agreed:
GeoJSON and Shapely write `west,south,east,north`, Overpass and Leaflet write
`south,west,north,east`, `gdal_translate -projwin` wants `west,north,east,south`, Nominatim
returns `[south, north, west, east]` as strings, and a box around Fiji has `west > east`.
bboxkit is a small, dependency-free value type that knows all of these conventions, so other
packages can depend on it instead of re-implementing (and re-debugging) them.

## Supported formats

| Format | Example | Parse | Format |
| --- | --- | --- | --- |
| W,S,E,N (GeoJSON, Shapely, OSM API, STAC) | `29,40.9,29.2,41.1` | `parse(s, order="wsen")` | `to_wsen()` |
| S,W,N,E (lat-first) | `40.9,29,41.1,29.2` | `parse(s, order="swne")` | `to_swne()` |
| bboxfinder.com | `http://bboxfinder.com/#40.9,29,41.1,29.2` | auto | `to_bboxfinder()` |
| Overpass QL | `(40.9,29,41.1,29.2)`, `[bbox:40.9,29,41.1,29.2]` | auto | `to_overpass()` |
| OSM URL | `...?bbox=W,S,E,N`, `?minlon=...`, `#map=14/41.0/29.0` | auto (`viewport=`) | `to_osm_url()` |
| WKT / EWKT | `POLYGON ((...))`, `SRID=3857;MULTIPOINT(...)`, any geometry | auto | `to_wkt()` |
| ENVELOPE (CQL, Solr) | `ENVELOPE(29, 29.2, 41.1, 40.9)` | auto | `to_wkt_envelope()` |
| PostGIS | `ST_MakeEnvelope(29, 40.9, 29.2, 41.1, 4326)`, `BOX(29 40.9,29.2 41.1)` | auto | `to_postgis()` |
| GeoJSON | `bbox` member, or computed from any geometry / Feature / FeatureCollection | auto | `to_geojson()` |
| Shapely / GeoPandas | `geom.bounds`, `gdf.total_bounds`, `__geo_interface__` | `parse(obj)` | `to_shapely_bounds()` |
| GDAL / OGR | `-te 29 40.9 29.2 41.1`, `-projwin 29 41.1 29.2 40.9`, `-spat ...`, `-te_srs` | auto | `to_gdal_te()`, `to_gdal_projwin()` |
| Leaflet / Google | `[[40.9, 29], [41.1, 29.2]]` | `parse_leaflet()` | `to_leaflet()` |
| Mapbox / MapLibre | `[[29, 40.9], [29.2, 41.1]]` | `parse_mapbox()` | `to_mapbox()` |
| XYZ tile | `14/9375/6142`, `https://tile.../14/9375/6142.png` | auto | `to_tile()` |
| Quadkey (Bing) | `1202102332221212` | auto | `to_quadkey()` |
| Geohash | `sxk9` | auto | `to_geohash()` |
| Plus Code (OLC) | `8FVC9G8F+6W` | auto | `to_pluscode()` |
| MGRS square | `35TPF`, `31U DQ 48251 11932` | auto | `to_mgrs()` |
| Dictionaries | Nominatim result, ArcGIS extent (`wkid`), STAC collection, Leaflet `_southWest`, Google `southwest`, `{minx, ...}` | `parse(d)` | `to_dict()` |
| Place name | `"Istanbul"` | `nominatim.geocode_bbox()` (opt-in, network) | |

For cell formats (tile, quadkey, geohash, Plus Code, MGRS) parsing gives the cell's box and
formatting gives the **smallest cell that contains the whole box**.

Not included, on purpose: H3 (needs the icosahedral projection, i.e. the `h3` library) and
the polar UPS part of MGRS.

## One-liners

```python
from bboxkit import BBox, parse, union_all

parse("http://bboxfinder.com/#40.9,29,41.1,29.2")         # BBox(29.0, 40.9, 29.2, 41.1)
parse("[bbox:40.9,29,41.1,29.2]")                          # Overpass setting
parse("gdalwarp -t_srs EPSG:32635 -te 500000 4500000 510000 4510000 in.tif out.tif")
parse("https://www.openstreetmap.org/#map=14/41.0082/28.9784", viewport=(1920, 1080))
parse("SRID=3857;POLYGON((0 0, 10 0, 10 10, 0 0))")       # crs='EPSG:3857'
parse({"type": "Feature", "geometry": {"type": "LineString", "coordinates": [[179, 0], [-179, 1]]}})
parse({"boundingbox": ["40.9", "41.1", "29.0", "29.2"]})   # a Nominatim result
parse(shapely_polygon)                                      # uses .bounds
parse("14/9375/6142"); parse("sxk9"); parse("8FVC9G8F+6W"); parse("35TPF")

box = BBox(29.0, 40.9, 29.2, 41.1)
box.to_wkt()               # 'POLYGON ((29 40.9, 29.2 40.9, 29.2 41.1, 29 41.1, 29 40.9))'
box.to_geojson()           # {'type': 'Polygon', 'coordinates': [...], 'bbox': [...]}
box.to_leaflet()           # [[40.9, 29.0], [41.1, 29.2]]
box.to_gdal_projwin()      # '-projwin 29 41.1 29.2 40.9'
box.to_osm_url()           # 'https://www.openstreetmap.org/#map=11/41.00008/29.10000'
box.format("postgis")      # 'ST_MakeEnvelope(29, 40.9, 29.2, 41.1, 4326)'
box.to_geohash(), box.to_tile(), box.to_mgrs()   # ('sxk', Tile(x=2, y=1, z=2), '35TPF')

box.area_km2()             # 373.741450485826  (exact for a lat/lon rectangle on WGS 84)
box.center                 # (29.1, 41.0)
box.width_m, box.height_m  # (16827.0..., 22210.7...)
box.buffer(500)            # grown by 500 m on every side
box.buffer(-500)           # shrunk by 500 m
box.contains((29.1, 41.0)); box.intersects(other); box & other; box | other
box.grid(4, 3)             # 12 cells, row by row from the north-west
box.split(1000)            # ~1 km x 1 km cells
box.tiles(14)              # [Tile(x=9511, y=6136, z=14), ...]
box.tile_count(14)         # 130
box.expand_to_aspect(16 / 9)            # fit a 16:9 web map (Mercator), never shrinks
BBox(29.03, 40.91, 29.17, 41.08).snap(0.1)   # BBox(29.0, 40.9, 29.2, 41.1)
round(box, 2)
box.to_crs("EPSG:3857")    # built in
box.to_utm()               # built in: EPSG:32635 for Istanbul
box.to_crs("EPSG:3035")    # any CRS with the [pyproj] extra
w, s, e, n = box           # unpacks like a tuple
```

## The antimeridian

For longitude/latitude boxes, `west > east` means the box crosses the 180th meridian, exactly
as in RFC 7946:

```python
>>> fiji = BBox(177, -20, -178, -16)
>>> fiji.crosses_antimeridian, fiji.width, fiji.center
(True, 5.0, (179.5, -18.0))
>>> fiji.contains((-179.5, -18))
True
>>> fiji.parts()
(BBox(177.0, -20.0, 180.0, -16.0), BBox(-180.0, -20.0, -178.0, -16.0))
>>> BBox(170, 0, 175, 1) | BBox(-175, 0, -170, 1)
BBox(170.0, 0.0, -170.0, 1.0)
>>> fiji.to_geojson()["type"]            # split into two parts, as RFC 7946 recommends
'MultiPolygon'
>>> fiji.to_leaflet()                    # one contiguous box on a web map
[[-20.0, 177.0], [-16.0, 182.0]]
```

- `union()` / `union_all()` and bounding boxes computed from GeoJSON or WKT use the smallest
  longitude range that covers everything (the complement of the largest gap), so the RFC 7946
  Fiji example gives `[177, -20, -178, -16]`, not `[-178, -20, 177, -16]`.
- A line or ring whose consecutive vertices are more than 180 degrees apart is read as crossing
  the antimeridian (the shorter way round).
- `intersection()` returns `None`, one box, or raises if the overlap is two separate pieces
  (wide boxes overlapping at both ends); `intersection_parts()` always returns the list.
- Formats that cannot express a crossing box (`-te`, `-projwin`, `ST_MakeEnvelope`, Shapely
  bounds) raise a `ValueError` that points to `parts()`.
- In Web Mercator a crossing box keeps contiguous x values (east beyond 20037508.34 m) and
  converts back to a crossing box.

## W,S,E,N or S,W,N,E?

Four bare numbers are ambiguous. bboxkit reads them as `west,south,east,north` unless only the
lat-first reading is valid, and emits an `AmbiguousOrderWarning` whenever both readings are
possible. Say what you mean to make the warning go away:

```python
parse("40.9,29,41.1,29.2", order="swne")
parse("swne:40.9,29,41.1,29.2")
parse(text, fmt="leaflet")
```

## Area, buffers and grids

- `area_km2()` uses the closed-form area of a region bounded by two meridians and two parallels
  on the WGS 84 ellipsoid (via the authalic latitude function). It matches a densely densified
  `pyproj.Geod` polygon to ~1e-11. 1 x 1 degree at the equator is 12308.4639 km2, at 60 N
  6123.1409 km2; the whole globe is 510065621.724 km2.
- `buffer(m)` uses the local radii of curvature; the longitude margin is taken at the most
  poleward latitude, so every point within `m` metres of the box is inside the result. Boxes
  that reach a pole get the full longitude range.
- `split(cell_m)` builds a regular lon/lat grid whose cells measure `cell_m` at the box's central
  latitude; `grid(nx, ny)` splits evenly. Both work across the antimeridian.
- Projected boxes (UTM, any metric CRS) do all of this in CRS units.

## Reprojection

`to_crs()` transforms densified edges (21 points per edge by default) and takes the extent.
WGS 84, Web Mercator (`EPSG:3857` and its aliases 900913, 3785, 102100) and WGS 84 / UTM
(`EPSG:326xx`, `EPSG:327xx`, ETRS89 `EPSG:258xx`) are built in; UTM uses the 6th-order Krueger
series (agrees with PROJ to nanometres). Anything else goes to pyproj when it is installed.
`to_utm()` picks the zone of the centre, including the Norway and Svalbard exceptions.

## Command line

```bash
$ bboxkit convert "29.0,40.9,29.2,41.1" --to overpass
bboxkit: warning: 29,40.9,29.2,41.1 is ambiguous; read as west,south,east,north. ...
(40.9,29,41.1,29.2)
$ bboxkit convert "29.0,40.9,29.2,41.1" --order wsen --to all
$ bboxkit convert "http://bboxfinder.com/#40.9,29,41.1,29.2" --to gdal-projwin
$ bboxkit area "29.0,40.9,29.2,41.1" --order wsen
373.741
$ bboxkit tiles "29.0,40.9,29.2,41.1" --order wsen --zoom 14 --count
130
$ bboxkit tiles "29.0,40.9,29.2,41.1" --order wsen --zoom 14 --format quadkey
$ bboxkit split "29.0,40.9,29.2,41.1" --order wsen --cell 1km --to geojson > grid.geojson
$ bboxkit split "29.0,40.9,29.2,41.1" --order wsen --grid 4x3
$ bboxkit buffer "POINT(29 41)" --distance 2km --to wkt
$ bboxkit reproject "29.0,40.9,29.2,41.1" --order wsen --to-crs utm
$ bboxkit info "177,-20,-178,-16" --order wsen
$ bboxkit geocode "Kadikoy, Istanbul" --user-agent "my-app/1.0 (me@example.org)"
$ bboxkit formats
```

Boxes that start with a minus sign (`-122.5,37.7,-122.3,37.8`, `-te 1 2 3 4`) work as they are;
`-` reads the box from standard input.

## Place names (optional, network)

`parse()` never touches the network. Place lookup is a separate, explicit call:

```python
from bboxkit.nominatim import geocode_bbox

geocode_bbox("Kadikoy, Istanbul", user_agent="my-app/1.0 (me@example.org)", timeout=10)
```

It follows the [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/):
at most one request per second per process, an identifying User-Agent (please set your own),
results cached in memory, and a `base_url=` for your own Nominatim instance when you need
more than occasional lookups. Results are (c) OpenStreetMap contributors, ODbL.

## Design

- `BBox` is immutable, hashable, picklable and slotted. `==` and `hash` are exact so boxes can
  be dict keys; `equals(other, tol=1e-9)` compares with a tolerance (longitudes modulo 360).
- Validation on construction: finite numbers, `south <= north`, latitudes and longitudes in
  range for geographic CRSs, `west <= east` for projected ones. `BBox.wrap()` accepts unwrapped
  longitudes such as `170 .. 190`.
- CRS strings are normalised (`4326`, `"epsg:4326"`, URNs, OGC URLs, `CRS84` all become
  `"EPSG:4326"`).
- Cell lookups (`to_geohash()`, `to_pluscode()`, ...) use exact integer arithmetic where the
  format allows it, so round trips are stable.

## Development

```bash
pip install -e ".[dev,pyproj]"
pytest --cov=bboxkit
ruff check . && ruff format --check src tests
mypy --strict src
```

## License

MIT, see [LICENSE](LICENSE). Copyright (c) 2026 Yusuf Eminoğlu.
