Coverage for gws-app/gws/lib/extent/__init__.py: 98%
86 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"""Utilities for extents.
3An extent (``gws.Extent``) is a tuple ``(x-min, y-min, x-max, y-max)`` of floats.
4Extents have no CRS of their own; for a geo-referenced extent, see ``gws.Bounds`` and ``gws.lib.bounds``.
6This package provides functions to:
8- create extents from strings, lists, points, a center point and size, or a PostGIS ``BOX``,
9- measure extents (center, size, width, height, diagonal),
10- combine extents (intersection, union, buffer, circumscribed square),
11- transform extents between CRSs,
12- validate extents.
14Example::
16 import gws.lib.crs
17 import gws.lib.extent
19 ext = gws.lib.extent.from_string('0,0,100,50')
20 gws.lib.extent.size(ext) # (100.0, 50.0)
21 gws.lib.extent.buffer(ext, 10) # (-10.0, -10.0, 110.0, 60.0)
23 wgs = gws.lib.extent.transform_to_wgs(ext, gws.lib.crs.WEBMERCATOR)
24"""
26from typing import Optional
28import math
29import re
31import gws
32import gws.lib.crs
35def from_string(s: str) -> Optional[gws.Extent]:
36 """Create an extent from a comma-separated string.
38 Args:
39 s: String like ``x-min,y-min,x-max,y-max``.
41 Returns:
42 An extent, or ``None`` if the string is not a valid extent.
43 """
45 return _from_string_list(s.split(','))
48def from_list(ls: list) -> Optional[gws.Extent]:
49 """Create an extent from a list.
51 Args:
52 ls: List ``[x-min, y-min, x-max, y-max]`` of numbers or numeric strings.
54 Returns:
55 An extent, or ``None`` if the list is not a valid extent.
56 """
58 return _from_string_list(ls)
61def from_points(a: gws.Point, b: gws.Point) -> gws.Extent:
62 """Create an extent from two opposite corner points.
64 Args:
65 a: First point.
66 b: Second point.
68 Returns:
69 The smallest extent that contains both points.
70 """
72 return (
73 min(a[0], b[0]),
74 min(a[1], b[1]),
75 max(a[0], b[0]),
76 max(a[1], b[1]),
77 )
80def from_center(xy: gws.Point, size: gws.Size) -> gws.Extent:
81 """Create an extent of a given size around a center point.
83 Args:
84 xy: Center point.
85 size: Width and height.
87 Returns:
88 An extent.
89 """
91 return (
92 xy[0] - size[0] / 2,
93 xy[1] - size[1] / 2,
94 xy[0] + size[0] / 2,
95 xy[1] + size[1] / 2,
96 )
99def from_box(box: str) -> Optional[gws.Extent]:
100 """Create an extent from a PostGIS box.
102 Args:
103 box: PostGIS box like ``BOX(minx miny,maxx maxy)``.
105 Returns:
106 An extent, or ``None`` if the box is empty or invalid.
107 """
109 if not box:
110 return None
112 m = re.match(r'^BOX\((.+?)\)$', str(box).upper())
113 if not m:
114 return None
116 return _from_string_list(m.group(1).replace(',', ' ').split(' '))
119#
122def intersection(*exts: gws.Extent) -> Optional[gws.Extent]:
123 """Create an extent that is the intersection of all given extents.
125 Args:
126 *exts: Extents.
128 Returns:
129 An extent, or ``None`` if the extents do not intersect or none are given.
130 """
132 if not exts:
133 return
135 res = (-math.inf, -math.inf, math.inf, math.inf)
137 for ext in exts:
138 if not intersect(res, ext):
139 return
140 res = (
141 max(res[0], ext[0]),
142 max(res[1], ext[1]),
143 min(res[2], ext[2]),
144 min(res[3], ext[3]),
145 )
146 return res
149def center(e: gws.Extent) -> gws.Point:
150 """Get the center point of an extent.
152 Args:
153 e: An extent.
155 Returns:
156 The center point.
157 """
159 return (
160 e[0] + (e[2] - e[0]) / 2,
161 e[1] + (e[3] - e[1]) / 2,
162 )
165def size(e: gws.Extent) -> gws.Size:
166 """Get the size of an extent.
168 Args:
169 e: An extent.
171 Returns:
172 A ``(width, height)`` tuple.
173 """
175 return (
176 e[2] - e[0],
177 e[3] - e[1],
178 )
181def w(e: gws.Extent) -> float:
182 """Get the width of an extent.
184 Args:
185 e: An extent.
187 Returns:
188 The width.
189 """
191 return e[2] - e[0]
194def h(e: gws.Extent) -> float:
195 """Get the height of an extent.
197 Args:
198 e: An extent.
200 Returns:
201 The height.
202 """
204 return e[3] - e[1]
207def diagonal(e: gws.Extent) -> float:
208 """Get the length of the diagonal of an extent.
210 Args:
211 e: An extent.
213 Returns:
214 The diagonal length.
215 """
217 return math.sqrt((e[2] - e[0]) ** 2 + (e[3] - e[1]) ** 2)
220def circumsquare(e: gws.Extent) -> gws.Extent:
221 """Get the square that circumscribes an extent.
223 The square has the same center as the extent, and its side equals the extent's diagonal,
224 so that it contains the extent at any rotation.
226 Args:
227 e: An extent.
229 Returns:
230 The square extent.
231 """
233 d = diagonal(e)
234 return from_center(center(e), (d, d))
237def buffer(e: gws.Extent, buf: float) -> gws.Extent:
238 """Create an extent with a buffer around another extent.
240 Args:
241 e: An extent.
242 buf: Buffer added on each side. A positive value makes the extent bigger, a negative one smaller.
244 Returns:
245 The buffered extent.
246 """
248 if buf == 0:
249 return e
250 return (
251 e[0] - buf,
252 e[1] - buf,
253 e[2] + buf,
254 e[3] + buf,
255 )
258def union(*exts: gws.Extent) -> gws.Extent:
259 """Create the smallest extent that contains all the given extents.
261 Args:
262 *exts: Extents, at least one.
264 Returns:
265 An extent.
266 """
268 ext = exts[0]
269 for e in exts:
270 ext = (
271 min(ext[0], e[0]),
272 min(ext[1], e[1]),
273 max(ext[2], e[2]),
274 max(ext[3], e[3]),
275 )
276 return ext
279def intersect(a: gws.Extent, b: gws.Extent) -> bool:
280 """Check if two extents intersect.
282 Extents that only touch at the edges are considered intersecting.
284 Args:
285 a: First extent.
286 b: Second extent.
288 Returns:
289 ``True`` if the extents intersect.
290 """
292 return a[0] <= b[2] and a[2] >= b[0] and a[1] <= b[3] and a[3] >= b[1]
295def transform(e: gws.Extent, crs_from: gws.Crs, crs_to: gws.Crs) -> gws.Extent:
296 """Transform an extent to a different coordinate reference system.
298 Args:
299 e: An extent.
300 crs_from: Source CRS.
301 crs_to: Target CRS.
303 Returns:
304 The transformed extent.
305 """
307 return crs_from.transform_extent(e, crs_to)
310def transform_from_wgs(e: gws.Extent, crs_to: gws.Crs) -> gws.Extent:
311 """Transform a WGS84 extent to a different coordinate reference system.
313 Args:
314 e: An extent in WGS84.
315 crs_to: Target CRS.
317 Returns:
318 The transformed extent.
319 """
321 return gws.lib.crs.WGS84.transform_extent(e, crs_to)
324def transform_to_wgs(e: gws.Extent, crs_from: gws.Crs) -> gws.Extent:
325 """Transform an extent to WGS84.
327 Args:
328 e: An extent.
329 crs_from: Source CRS.
331 Returns:
332 The WGS84 extent.
333 """
335 return crs_from.transform_extent(e, gws.lib.crs.WGS84)
338def swap_xy(e: gws.Extent) -> gws.Extent:
339 """Swap the x and y values of an extent.
341 Args:
342 e: An extent.
344 Returns:
345 The extent ``(y-min, x-min, y-max, x-max)``.
346 """
347 return e[1], e[0], e[3], e[2]
350def is_valid(e: gws.Extent) -> bool:
351 """Check if an extent is valid.
353 A valid extent has four finite values, and its minimum values are less than its maximum values.
355 Args:
356 e: An extent.
358 Returns:
359 ``True`` if the extent is valid.
360 """
362 if not e or len(e) != 4:
363 return False
364 if not all(math.isfinite(p) for p in e):
365 return False
366 if e[0] >= e[2] or e[1] >= e[3]:
367 return False
368 return True
371def is_valid_wgs(e: gws.Extent) -> bool:
372 """Check if an extent is valid and lies within the WGS84 bounds.
374 Args:
375 e: An extent in WGS84.
377 Returns:
378 ``True`` if the extent is valid and within ``(-180, -90, 180, 90)``.
379 """
381 if not is_valid(e):
382 return False
383 w = gws.lib.crs.WGS84.extent
384 return e[0] >= w[0] and e[1] >= w[1] and e[2] <= w[2] and e[3] <= w[3]
387def _from_string_list(ls: list) -> Optional[gws.Extent]:
388 if len(ls) != 4:
389 return None
390 try:
391 e = [float(p) for p in ls]
392 except ValueError:
393 return None
394 if not all(math.isfinite(p) for p in e):
395 return None
396 if e[0] >= e[2] or e[1] >= e[3]:
397 return None
398 return e[0], e[1], e[2], e[3]