Coverage for gws-app/gws/lib/shape/__init__.py: 56%
246 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"""Shapes.
3A shape (``gws.Shape``) is a geo-referenced geometry: a Shapely geometry
4together with a ``gws.Crs``. Shapes are used for feature geometries, search
5geometries and map extents throughout the application.
7The package is a single module. It provides:
9- constructors that create a ``Shape`` from WKT and EWKT, WKB and EWKB (binary or
10 hex), SQLAlchemy/GeoAlchemy WKB elements, GeoJSON geometries, shape props or
11 dicts, extents, ``gws.Bounds`` and x/y coordinates,
12- the ``Shape`` class, which implements the ``gws.Shape`` interface: conversion to
13 WKB, WKT, GeoJSON and props, spatial predicates, union and intersection, type
14 conversions, buffering with a tolerance and transformation to other CRS,
15- the ``Props`` class for shapes sent to and from the client.
17Constructors raise ``Error`` if the input cannot be parsed or has no CRS.
18EWKT and EWKB inputs carry their own SRID; for plain WKT and WKB a default CRS
19must be given. GeoJSON inputs and extents are expected in the axis order of the
20CRS, unless ``always_xy`` is set. Circles (``{"type": "Circle", "center": ...,
21"radius": ...}``), as sent by the client, are converted to polygons.
23Binary predicates and set operations transform the other shape to the CRS of
24this shape first. ``to_geojson`` transforms to WGS84 unless asked to keep the CRS.
26Example::
28 import gws.lib.shape
29 import gws.lib.crs
31 shape = gws.lib.shape.from_wkt('POINT(10 20)', gws.lib.crs.WGS84)
32 area = shape.tolerance_polygon(5).transformed_to(gws.lib.crs.WEBMERCATOR)
33 ewkt = area.to_ewkt()
35 other = gws.lib.shape.from_wkt('SRID=4326;POLYGON((0 0,30 0,30 30,0 30,0 0))')
36 print(other.contains(shape))
37"""
39# @TODO support for SQL/MM extensions
41import struct
42import re
43import shapely.errors
44import shapely.geometry
45import shapely.ops
46import shapely.wkb
47import shapely.wkt
49import gws
50import gws.lib.crs
51import gws.lib.sa as sa
53_TOLERANCE_QUAD_SEGS = 6
54_MIN_TOLERANCE_RADIUS = 0.01
57class Error(gws.Error):
58 """Invalid geometry or CRS."""
60 pass
63def from_wkt(wkt: str, default_crs: gws.Crs = None) -> gws.Shape:
64 """Create a shape from a WKT or EWKT string.
66 Args:
67 wkt: A WKT or EWKT string.
68 default_crs: CRS to use if the string has no SRID.
70 Returns:
71 A Shape object.
73 Raises:
74 ``Error``: If the string is invalid or there is no CRS.
75 """
77 if wkt.startswith('SRID='):
78 # EWKT
79 c = wkt.index(';')
80 srid = wkt[len('SRID=') : c]
81 crs = gws.lib.crs.require(int(srid))
82 wkt = wkt[c + 1 :]
83 elif default_crs:
84 crs = default_crs
85 else:
86 raise Error('missing or invalid crs for WKT')
88 try:
89 geom = shapely.wkt.loads(wkt)
90 except shapely.errors.ShapelyError as exc:
91 raise Error('invalid WKT') from exc
92 return Shape(geom, crs)
95def from_wkb(wkb: bytes, default_crs: gws.Crs = None) -> gws.Shape:
96 """Create a shape from a WKB or EWKB byte string.
98 Args:
99 wkb: A WKB or EWKB byte string.
100 default_crs: CRS to use if the data has no SRID.
102 Returns:
103 A Shape object.
105 Raises:
106 ``Error``: If the data is invalid or there is no CRS.
107 """
109 return _from_wkb(wkb, default_crs)
112def from_wkb_hex(wkb: str, default_crs: gws.Crs = None) -> gws.Shape:
113 """Create a shape from a hex-encoded WKB or EWKB string.
115 Args:
116 wkb: A hex-encoded WKB or EWKB string.
117 default_crs: CRS to use if the data has no SRID.
119 Returns:
120 A Shape object.
122 Raises:
123 ``Error``: If the data is invalid or there is no CRS.
124 """
126 try:
127 b = bytes.fromhex(wkb)
128 except ValueError as exc:
129 raise Error('invalid WKB hex') from exc
130 return _from_wkb(b, default_crs)
133def _from_wkb(wkb: bytes, default_crs):
134 """Create a shape from WKB or EWKB bytes, reading the SRID from the EWKB header."""
135 # http://libgeos.org/specifications/wkb/#extended-wkb
137 try:
138 byte_order = wkb[0]
139 header = struct.unpack('<cLL' if byte_order == 1 else '>cLL', wkb[:9])
140 except (IndexError, struct.error) as exc:
141 raise Error('invalid WKB') from exc
143 if header[1] & 0x20000000:
144 crs = gws.lib.crs.require(header[2])
145 elif default_crs:
146 crs = default_crs
147 else:
148 raise Error('missing or invalid crs for WKB')
150 try:
151 geom = shapely.wkb.loads(wkb)
152 except shapely.errors.ShapelyError as exc:
153 raise Error('invalid WKB') from exc
154 return Shape(geom, crs)
157def from_wkb_element(element: sa.geo.WKBElement, default_crs: gws.Crs = None):
158 """Create a shape from a GeoAlchemy WKB element.
160 The CRS is taken from the EWKB data, then from the SRID of the element, then
161 from ``default_crs``.
163 Args:
164 element: A WKB element, with binary or hex-encoded data.
165 default_crs: CRS to use if neither the data nor the element has a valid SRID.
167 Returns:
168 A Shape object.
170 Raises:
171 ``Error``: If the data is invalid or there is no CRS.
172 """
173 data = element.data
174 if isinstance(data, str):
175 wkb = bytes.fromhex(data)
176 else:
177 wkb = bytes(data)
178 crs = gws.lib.crs.get(element.srid)
179 return _from_wkb(wkb, crs or default_crs)
182def from_geojson(geojson: dict, crs: gws.Crs, always_xy=False) -> gws.Shape:
183 """Create a shape from a GeoJSON geometry dict.
185 Parses a dict as a GeoJSON geometry object (https://www.rfc-editor.org/rfc/rfc7946#section-3.1).
186 A ``Circle`` geometry with ``center`` and ``radius`` is converted to a polygon.
187 The coordinates are assumed to be in the axis order of the CRS, unless ``always_xy`` is ``True``.
189 Args:
190 geojson: A GeoJSON geometry dict.
191 crs: A Crs object.
192 always_xy: If ``True``, coordinates are assumed to be in the XY (lon/lat) order.
194 Returns:
195 A Shape object.
197 Raises:
198 ``Error``: If the geometry is invalid.
199 """
201 geom = _shapely_shape(geojson)
202 if crs.isYX and not always_xy:
203 geom = _swap_xy(geom)
204 return Shape(geom, crs)
207def from_props(props: gws.Props) -> gws.Shape:
208 """Create a shape from a properties object.
210 Args:
211 props: A properties object with ``crs`` and ``geometry`` (a GeoJSON geometry dict).
213 Returns:
214 A Shape object.
216 Raises:
217 ``Error``: If the CRS or the geometry is invalid.
218 """
220 crs = gws.lib.crs.get(props.get('crs'))
221 if not crs:
222 raise Error('missing or invalid crs')
223 geom = _shapely_shape(props.get('geometry'))
224 return Shape(geom, crs)
227def from_dict(d: dict) -> gws.Shape:
228 """Create a shape from a dictionary.
230 Args:
231 d: A dictionary with the keys ``crs`` and ``geometry`` (a GeoJSON geometry dict).
233 Returns:
234 A Shape object.
236 Raises:
237 ``Error``: If the CRS or the geometry is invalid.
238 """
240 crs = gws.lib.crs.get(d.get('crs'))
241 if not crs:
242 raise Error('missing or invalid crs')
243 geom = _shapely_shape(d.get('geometry'))
244 return Shape(geom, crs)
247def from_extent(extent: gws.Extent, crs: gws.Crs, always_xy=False) -> gws.Shape:
248 """Create a polygon shape from an extent.
250 Args:
251 extent: An extent.
252 crs: A Crs object.
253 always_xy: If ``True``, the extent is assumed to be in the XY (lon/lat) order,
254 otherwise in the axis order of the CRS.
256 Returns:
257 A Shape object.
258 """
260 geom = shapely.geometry.box(*extent)
261 if crs.isYX and not always_xy:
262 geom = _swap_xy(geom)
263 return Shape(geom, crs)
266def from_bounds(bounds: gws.Bounds) -> gws.Shape:
267 """Create a polygon shape from a Bounds object.
269 Args:
270 bounds: A Bounds object.
272 Returns:
273 A Shape object.
274 """
276 return Shape(shapely.geometry.box(*bounds.extent), bounds.crs)
279def from_xy(x: float, y: float, crs: gws.Crs) -> gws.Shape:
280 """Create a point shape from coordinates.
282 Args:
283 x: X coordinate (lon/easting).
284 y: Y coordinate (lat/northing).
285 crs: A Crs object.
287 Returns:
288 A Shape object.
289 """
291 return Shape(shapely.geometry.Point(x, y), crs)
294def _swap_xy(geom):
295 """Return a copy of a Shapely geometry with x and y swapped."""
296 def f(x: float, y: float, z: float = None) -> tuple[float, float]:
297 return y, x
299 return shapely.ops.transform(f, geom)
302_CIRCLE_RESOLUTION = 64
305def _shapely_shape(d):
306 """Create a Shapely geometry from a GeoJSON dict, raising ``Error`` if it is invalid."""
307 try:
308 return _shapely_shape2(d)
309 except (shapely.errors.ShapelyError, AttributeError, TypeError, ValueError) as exc:
310 raise Error('invalid geometry') from exc
313def _shapely_shape2(d):
314 """Create a Shapely geometry from a GeoJSON dict, converting circles to polygons."""
315 if d.get('type').upper() == 'CIRCLE':
316 geom = shapely.geometry.Point(d.get('center'))
317 return geom.buffer(
318 d.get('radius'),
319 resolution=_CIRCLE_RESOLUTION,
320 cap_style=shapely.geometry.CAP_STYLE.round,
321 join_style=shapely.geometry.JOIN_STYLE.round,
322 )
324 return shapely.geometry.shape(d)
327##
330class Props(gws.Props):
331 """Shape properties object."""
333 crs: str
334 geometry: dict
337##
340class Shape(gws.Shape):
341 """Shape implemented with a Shapely geometry."""
343 geom: shapely.geometry.base.BaseGeometry
344 """Shapely geometry."""
346 def __init__(self, geom, crs: gws.Crs):
347 """Create a shape.
349 Args:
350 geom: Shapely geometry.
351 crs: CRS of the geometry.
352 """
353 super().__init__()
354 self.geom = geom
355 self.crs = crs
356 self.type = self.geom.geom_type.lower()
357 self.x = getattr(self.geom, 'x', None)
358 self.y = getattr(self.geom, 'y', None)
360 def __str__(self):
361 return '{Geometry:' + self.geom.geom_type.upper() + '}'
363 def area(self):
364 return getattr(self.geom, 'area', 0)
366 def bounds(self):
367 return gws.Bounds(crs=self.crs, extent=self.geom.bounds)
369 def centroid(self):
370 return Shape(self.geom.centroid, self.crs)
372 def center(self):
373 c = self.geom.centroid
374 return c.x, c.y
376 def to_wkb(self):
377 return shapely.wkb.dumps(self.geom)
379 def to_wkb_hex(self):
380 return shapely.wkb.dumps(self.geom, hex=True)
382 def to_ewkb(self):
383 return shapely.wkb.dumps(self.geom, srid=self.crs.srid)
385 def to_ewkb_hex(self):
386 return shapely.wkb.dumps(self.geom, srid=self.crs.srid, hex=True)
388 def to_wkt(self, trim=False, rounding_precision=-1, output_dimension=3):
389 s = shapely.wkt.dumps(self.geom, trim=trim, rounding_precision=rounding_precision, output_dimension=output_dimension)
390 s = re.sub(r'\s*([,()])\s*', r'\1', s)
391 s = re.sub(r'\s+', ' ', s.strip())
392 return s
394 def to_ewkt(self, trim=False, rounding_precision=-1, output_dimension=3):
395 return f'SRID={self.crs.srid};' + self.to_wkt(trim=trim, rounding_precision=rounding_precision, output_dimension=output_dimension)
397 def to_geojson(self, keep_crs=False):
398 # see https://datatracker.ietf.org/doc/html/rfc7946#section-4
399 # convert to WGS lon,lat unless keep_crs is true
400 # coords order is always XY
402 if keep_crs or self.crs == gws.lib.crs.WGS84:
403 return shapely.geometry.mapping(self.geom)
405 tr = self.crs.transformer(gws.lib.crs.WGS84)
406 new_geom = shapely.ops.transform(tr, self.geom)
407 return shapely.geometry.mapping(new_geom)
409 def to_precision(self, prec: int):
410 geom = shapely.set_precision(self.geom, 10 ** -prec)
411 return Shape(geom, self.crs)
413 def to_props(self):
414 return gws.ShapeProps(crs=self.crs.epsg, geometry=shapely.geometry.mapping(self.geom))
416 def is_empty(self):
417 return self.geom.is_empty
419 def is_ring(self):
420 return self.geom.is_ring
422 def is_simple(self):
423 return self.geom.is_simple
425 def is_valid(self):
426 return self.geom.is_valid
428 def equals(self, other):
429 return self._binary_predicate(other, 'equals')
431 def contains(self, other):
432 return self._binary_predicate(other, 'contains')
434 def covers(self, other):
435 return self._binary_predicate(other, 'covers')
437 def covered_by(self, other):
438 return self._binary_predicate(other, 'covered_by')
440 def crosses(self, other):
441 return self._binary_predicate(other, 'crosses')
443 def disjoint(self, other):
444 return self._binary_predicate(other, 'disjoint')
446 def intersects(self, other):
447 return self._binary_predicate(other, 'intersects')
449 def overlaps(self, other):
450 return self._binary_predicate(other, 'overlaps')
452 def touches(self, other):
453 return self._binary_predicate(other, 'touches')
455 def within(self, other):
456 return self._binary_predicate(other, 'within')
458 def _binary_predicate(self, other, op):
459 """Apply a Shapely predicate to this shape and another one, transformed to this CRS."""
460 s = other.transformed_to(self.crs)
461 return getattr(self.geom, op)(getattr(s, 'geom'))
463 def union(self, others):
464 if not others:
465 return self
467 geoms = [self.geom]
468 for s in others:
469 s = s.transformed_to(self.crs)
470 geoms.append(getattr(s, 'geom'))
472 geom = shapely.ops.unary_union(geoms)
473 return Shape(geom, self.crs)
475 def intersection(self, *others):
476 if not others:
477 return self
479 geom = self.geom
480 for s in others:
481 s = s.transformed_to(self.crs)
482 geom = geom.intersection(getattr(s, 'geom'))
484 return Shape(geom, self.crs)
486 def to_multi(self):
487 if self.type == gws.GeometryType.point:
488 return Shape(shapely.geometry.MultiPoint([self.geom]), self.crs)
489 if self.type == gws.GeometryType.linestring:
490 return Shape(shapely.geometry.MultiLineString([self.geom]), self.crs)
491 if self.type == gws.GeometryType.polygon:
492 return Shape(shapely.geometry.MultiPolygon([self.geom]), self.crs)
493 return self
495 def to_type(self, new_type: gws.GeometryType):
496 if new_type == self.type:
497 return self
498 if new_type == gws.GeometryType.geometry:
499 return self
500 if self.type == gws.GeometryType.point and new_type == gws.GeometryType.multipoint:
501 return self.to_multi()
502 if self.type == gws.GeometryType.linestring and new_type == gws.GeometryType.multilinestring:
503 return self.to_multi()
504 if self.type == gws.GeometryType.polygon and new_type == gws.GeometryType.multipolygon:
505 return self.to_multi()
506 raise Error(f'cannot convert {self.type!r} to {new_type!r}')
508 def to_2d(self):
509 geom = shapely.force_2d(self.geom)
510 if geom is self.geom:
511 return self
512 return Shape(geom, self.crs)
514 def tolerance_polygon(self, tolerance=None, quad_segs=None):
515 is_poly = self.type in (gws.GeometryType.polygon, gws.GeometryType.multipolygon)
517 if not tolerance and is_poly:
518 return self
520 # we need a polygon even if tolerance = 0
521 tolerance = tolerance or _MIN_TOLERANCE_RADIUS
522 quad_segs = quad_segs or _TOLERANCE_QUAD_SEGS
524 geom = self.geom
525 if self.crs.isGeographic:
526 tr = self.crs.transformer(gws.lib.crs.WEBMERCATOR)
527 geom = shapely.ops.transform(tr, self.geom)
529 if is_poly:
530 cs = shapely.geometry.CAP_STYLE.flat
531 js = shapely.geometry.JOIN_STYLE.mitre
532 else:
533 cs = shapely.geometry.CAP_STYLE.round
534 js = shapely.geometry.JOIN_STYLE.round
536 geom = geom.buffer(tolerance, quad_segs, cap_style=cs, join_style=js)
537 if self.crs.isGeographic:
538 tr = gws.lib.crs.WEBMERCATOR.transformer(self.crs)
539 geom = shapely.ops.transform(tr, geom)
541 return Shape(geom, self.crs)
543 def transformed_to(self, crs):
544 if crs == self.crs:
545 return self
546 tr = self.crs.transformer(crs)
547 dg = shapely.ops.transform(tr, self.geom)
548 return Shape(dg, crs)