Coverage for gws-app/gws/lib/bounds/__init__.py: 96%
49 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Bounds utilities.
3A ``gws.Bounds`` object is an extent together with its CRS. This package
4creates bounds from extents and OGC ``BBOX`` request parameters, transforms
5them between CRS, and combines, compares and buffers them. Functions that
6combine bounds in different CRS transform them to the CRS of the first
7argument. Extents without a CRS are handled by ``gws.lib.extent``.
9When bounds are created from external input, the axis order of the CRS is
10respected: for a CRS with the YX (lat/lon) axis order, the coordinates are
11swapped, unless the input is known to be in the XY order.
13Example::
15 b = gws.lib.bounds.from_request_bbox('7,50,8,51', gws.lib.crs.WGS84, always_xy=True)
16 b = gws.lib.bounds.transform(b, gws.lib.crs.WEBMERCATOR)
17 b = gws.lib.bounds.buffer(b, 100)
18 wgs = gws.lib.bounds.wgs_extent(b)
19"""
21from typing import Optional
23import gws
24import gws.lib.crs
25import gws.lib.extent
26import gws.lib.gml
28_PAD = 1e-3
29_MIN_PAD = 1e-6
32def from_request_bbox(bbox: str, default_crs: gws.Crs = None, always_xy=False) -> Optional[gws.Bounds]:
33 """Create bounds from a KVP ``BBOX`` parameter.
35 See OGC 06-121r9, 10.2.3 Bounding box KVP encoding.
37 Args:
38 bbox: Four comma-separated coordinates, optionally followed by a CRS name.
39 default_crs: CRS to use if the parameter has no CRS.
40 always_xy: If ``True``, coordinates are assumed to be in the XY (lon/lat) order.
42 Returns:
43 Bounds, or ``None`` if the parameter is empty or invalid, or there is no CRS.
44 """
46 if not bbox:
47 return None
49 crs = default_crs
51 # x,y,x,y,crs
52 ls = bbox.split(',')
53 if len(ls) == 5:
54 crs = gws.lib.crs.get(ls.pop())
56 if not crs:
57 return None
59 extent = gws.lib.extent.from_list(ls)
60 if not extent:
61 return None
63 return from_extent(extent, crs, always_xy)
66def from_extent(extent: gws.Extent, crs: gws.Crs, always_xy=False) -> gws.Bounds:
67 """Create bounds from an extent.
69 If the CRS has the YX axis order, the extent coordinates are swapped, unless ``always_xy`` is set.
71 Args:
72 extent: Extent.
73 crs: CRS of the extent.
74 always_xy: If ``True``, coordinates are assumed to be in the XY (lon/lat) order.
76 Returns:
77 Bounds.
78 """
80 if crs.isYX and not always_xy:
81 extent = gws.lib.extent.swap_xy(extent)
83 return gws.Bounds(crs=crs, extent=extent)
86def copy(b: gws.Bounds) -> gws.Bounds:
87 """Copy bounds.
89 Args:
90 b: Bounds.
92 Returns:
93 New bounds with the same CRS and extent.
94 """
95 return gws.Bounds(crs=b.crs, extent=b.extent)
98def union(bs: list[gws.Bounds]) -> gws.Bounds:
99 """Create the smallest bounds that contain all given bounds.
101 Args:
102 bs: A non-empty list of bounds.
104 Returns:
105 Bounds in the CRS of the first element of ``bs``.
106 """
108 crs = bs[0].crs
109 exts = [gws.lib.extent.transform(b.extent, b.crs, crs) for b in bs]
110 return gws.Bounds(
111 crs=crs,
112 extent=gws.lib.extent.union(*exts),
113 )
116def intersect(b1: gws.Bounds, b2: gws.Bounds) -> bool:
117 """Check if two bounds intersect.
119 Args:
120 b1: First bounds.
121 b2: Second bounds, transformed to the CRS of ``b1`` for the check.
123 Returns:
124 ``True`` if the bounds intersect.
125 """
126 e1 = b1.extent
127 e2 = gws.lib.extent.transform(b2.extent, crs_from=b2.crs, crs_to=b1.crs)
128 return gws.lib.extent.intersect(e1, e2)
131def transform(b: gws.Bounds, crs_to: gws.Crs) -> gws.Bounds:
132 """Transform bounds to a different CRS.
134 Args:
135 b: Bounds.
136 crs_to: Target CRS.
138 Returns:
139 Bounds in the target CRS, or ``b`` itself if it is already in that CRS.
140 """
141 if b.crs == crs_to:
142 return b
143 return gws.Bounds(
144 crs=crs_to,
145 extent=b.crs.transform_extent(b.extent, crs_to),
146 )
149def wgs_extent(b: gws.Bounds, pad: bool = False) -> Optional[gws.Extent]:
150 """Transform bounds to a WGS84 extent.
152 Args:
153 b: Bounds.
154 pad: Enlarge the extent slightly before the transformation, so that features on the edges
155 of a data-derived extent survive the round trip through WGS84.
157 Returns:
158 A WGS84 extent, or ``None`` if the result is invalid.
159 """
161 ext = b.extent
162 if pad:
163 buf = max(gws.lib.extent.w(ext) * _PAD, gws.lib.extent.h(ext) * _PAD, _MIN_PAD)
164 ext = gws.lib.extent.buffer(ext, buf)
165 ext = gws.lib.extent.transform(ext, b.crs, gws.lib.crs.WGS84)
166 return ext if gws.lib.extent.is_valid(ext) else None
169def buffer(b: gws.Bounds, buf_size: float) -> gws.Bounds:
170 """Enlarge or shrink bounds by a buffer.
172 Args:
173 b: Bounds.
174 buf_size: Buffer size in CRS units. A positive buffer enlarges the bounds, a negative one shrinks them.
176 Returns:
177 New bounds, or ``b`` itself if the buffer is 0.
178 """
179 if buf_size == 0:
180 return b
181 return gws.Bounds(crs=b.crs, extent=gws.lib.extent.buffer(b.extent, buf_size))