Coverage for gws-app/gws/lib/extent/__init__.py: 98%
88 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +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.
131 Raises:
132 ``gws.Error``: If no extents are given.
133 """
135 if not exts:
136 raise gws.Error('no extents given')
138 res = (-math.inf, -math.inf, math.inf, math.inf)
140 for ext in exts:
141 if not intersect(res, ext):
142 return
143 res = (
144 max(res[0], ext[0]),
145 max(res[1], ext[1]),
146 min(res[2], ext[2]),
147 min(res[3], ext[3]),
148 )
149 return res
152def center(e: gws.Extent) -> gws.Point:
153 """Get the center point of an extent.
155 Args:
156 e: An extent.
158 Returns:
159 The center point.
160 """
162 return (
163 e[0] + (e[2] - e[0]) / 2,
164 e[1] + (e[3] - e[1]) / 2,
165 )
168def size(e: gws.Extent) -> gws.Size:
169 """Get the size of an extent.
171 Args:
172 e: An extent.
174 Returns:
175 A ``(width, height)`` tuple.
176 """
178 return (
179 e[2] - e[0],
180 e[3] - e[1],
181 )
184def w(e: gws.Extent) -> float:
185 """Get the width of an extent.
187 Args:
188 e: An extent.
190 Returns:
191 The width.
192 """
194 return e[2] - e[0]
197def h(e: gws.Extent) -> float:
198 """Get the height of an extent.
200 Args:
201 e: An extent.
203 Returns:
204 The height.
205 """
207 return e[3] - e[1]
210def diagonal(e: gws.Extent) -> float:
211 """Get the length of the diagonal of an extent.
213 Args:
214 e: An extent.
216 Returns:
217 The diagonal length.
218 """
220 return math.sqrt((e[2] - e[0]) ** 2 + (e[3] - e[1]) ** 2)
223def circumsquare(e: gws.Extent) -> gws.Extent:
224 """Get the square that circumscribes an extent.
226 The square has the same center as the extent, and its side equals the extent's diagonal,
227 so that it contains the extent at any rotation.
229 Args:
230 e: An extent.
232 Returns:
233 The square extent.
234 """
236 d = diagonal(e)
237 return from_center(center(e), (d, d))
240def buffer(e: gws.Extent, buf: float) -> gws.Extent:
241 """Create an extent with a buffer around another extent.
243 Args:
244 e: An extent.
245 buf: Buffer added on each side. A positive value makes the extent bigger, a negative one smaller.
247 Returns:
248 The buffered extent.
249 """
251 if buf == 0:
252 return e
253 return (
254 e[0] - buf,
255 e[1] - buf,
256 e[2] + buf,
257 e[3] + buf,
258 )
261def union(*exts: gws.Extent) -> gws.Extent:
262 """Create the smallest extent that contains all the given extents.
264 Args:
265 *exts: Extents, at least one.
267 Returns:
268 An extent.
270 Raises:
271 ``gws.Error``: If no extents are given.
272 """
274 if not exts:
275 raise gws.Error('no extents given')
277 ext = exts[0]
278 for e in exts:
279 ext = (
280 min(ext[0], e[0]),
281 min(ext[1], e[1]),
282 max(ext[2], e[2]),
283 max(ext[3], e[3]),
284 )
285 return ext
288def intersect(a: gws.Extent, b: gws.Extent) -> bool:
289 """Check if two extents intersect.
291 Extents that only touch at the edges are considered intersecting.
293 Args:
294 a: First extent.
295 b: Second extent.
297 Returns:
298 ``True`` if the extents intersect.
299 """
301 return a[0] <= b[2] and a[2] >= b[0] and a[1] <= b[3] and a[3] >= b[1]
304def transform(e: gws.Extent, crs_from: gws.Crs, crs_to: gws.Crs) -> gws.Extent:
305 """Transform an extent to a different coordinate reference system.
307 Args:
308 e: An extent.
309 crs_from: Source CRS.
310 crs_to: Target CRS.
312 Returns:
313 The transformed extent.
314 """
316 return crs_from.transform_extent(e, crs_to)
319def transform_from_wgs(e: gws.Extent, crs_to: gws.Crs) -> gws.Extent:
320 """Transform a WGS84 extent to a different coordinate reference system.
322 Args:
323 e: An extent in WGS84.
324 crs_to: Target CRS.
326 Returns:
327 The transformed extent.
328 """
330 return gws.lib.crs.WGS84.transform_extent(e, crs_to)
333def transform_to_wgs(e: gws.Extent, crs_from: gws.Crs) -> gws.Extent:
334 """Transform an extent to WGS84.
336 Args:
337 e: An extent.
338 crs_from: Source CRS.
340 Returns:
341 The WGS84 extent.
342 """
344 return crs_from.transform_extent(e, gws.lib.crs.WGS84)
347def swap_xy(e: gws.Extent) -> gws.Extent:
348 """Swap the x and y values of an extent.
350 Args:
351 e: An extent.
353 Returns:
354 The extent ``(y-min, x-min, y-max, x-max)``.
355 """
356 return e[1], e[0], e[3], e[2]
359def is_valid(e: gws.Extent) -> bool:
360 """Check if an extent is valid.
362 A valid extent has four finite values, and its minimum values are less than its maximum values.
364 Args:
365 e: An extent.
367 Returns:
368 ``True`` if the extent is valid.
369 """
371 if not e or len(e) != 4:
372 return False
373 if not all(math.isfinite(p) for p in e):
374 return False
375 if e[0] >= e[2] or e[1] >= e[3]:
376 return False
377 return True
380def is_valid_wgs(e: gws.Extent) -> bool:
381 """Check if an extent is valid and lies within the WGS84 bounds.
383 Args:
384 e: An extent in WGS84.
386 Returns:
387 ``True`` if the extent is valid and within ``(-180, -90, 180, 90)``.
388 """
390 if not is_valid(e):
391 return False
392 w = gws.lib.crs.WGS84.extent
393 return e[0] >= w[0] and e[1] >= w[1] and e[2] <= w[2] and e[3] <= w[3]
396def _from_string_list(ls: list) -> Optional[gws.Extent]:
397 if len(ls) != 4:
398 return None
399 try:
400 e = [float(p) for p in ls]
401 except ValueError:
402 return None
403 if not all(math.isfinite(p) for p in e):
404 return None
405 if e[0] >= e[2] or e[1] >= e[3]:
406 return None
407 return e[0], e[1], e[2], e[3]