Coverage for gws-app/gws/base/shape/__init__.py: 58%
222 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
1"""Shape object.
3The Shape object represents a geo-referenced geometry.
4Internally, it holds a pointer to a Shapely geometry object and a Crs object.
5"""
7# @TODO support for SQL/MM extensions
9import struct
10import re
11import shapely.geometry
12import shapely.ops
13import shapely.wkb
14import shapely.wkt
16import gws
17import gws.lib.crs
18import gws.lib.sa as sa
20_TOLERANCE_QUAD_SEGS = 6
21_MIN_TOLERANCE_RADIUS = 0.01
24class Error(gws.Error):
25 pass
28def from_wkt(wkt: str, default_crs: gws.Crs = None) -> gws.Shape:
29 """Creates a shape object from a WKT string.
31 Args:
32 wkt: A WKT or EWKT string.
33 default_crs: Default Crs.
35 Returns:
36 A Shape object.
37 """
39 if wkt.startswith('SRID='):
40 # EWKT
41 c = wkt.index(';')
42 srid = wkt[len('SRID=') : c]
43 crs = gws.lib.crs.require(int(srid))
44 wkt = wkt[c + 1 :]
45 elif default_crs:
46 crs = default_crs
47 else:
48 raise Error('missing or invalid crs for WKT')
50 return Shape(shapely.wkt.loads(wkt), crs)
53def from_wkb(wkb: bytes, default_crs: gws.Crs = None) -> gws.Shape:
54 """Creates a shape object from a WKB byte string.
56 Args:
57 wkb: A WKB or EWKB byte string.
58 default_crs: Default Crs.
60 Returns:
61 A Shape object.
62 """
64 return _from_wkb(wkb, default_crs)
67def from_wkb_hex(wkb: str, default_crs: gws.Crs = None) -> gws.Shape:
68 """Creates a shape object from a hex-encoded WKB string.
70 Args:
71 wkb: A hex-encoded WKB or EWKB byte string.
72 default_crs: Default Crs.
74 Returns:
75 A Shape object.
76 """
78 return _from_wkb(bytes.fromhex(wkb), default_crs)
81def _from_wkb(wkb: bytes, default_crs):
82 # http://libgeos.org/specifications/wkb/#extended-wkb
84 byte_order = wkb[0]
85 header = struct.unpack('<cLL' if byte_order == 1 else '>cLL', wkb[:9])
87 if header[1] & 0x20000000:
88 crs = gws.lib.crs.require(header[2])
89 elif default_crs:
90 crs = default_crs
91 else:
92 raise Error('missing or invalid crs for WKB')
94 geom = shapely.wkb.loads(wkb)
95 return Shape(geom, crs)
98def from_wkb_element(element: sa.geo.WKBElement, default_crs: gws.Crs = None):
99 data = element.data
100 if isinstance(data, str):
101 wkb = bytes.fromhex(data)
102 else:
103 wkb = bytes(data)
104 crs = gws.lib.crs.get(element.srid)
105 return _from_wkb(wkb, crs or default_crs)
108def from_geojson(geojson: dict, crs: gws.Crs, always_xy=False) -> gws.Shape:
109 """Creates a shape object from a GeoJSON geometry dict.
111 Parses a dict as a GeoJSON geometry object (https://www.rfc-editor.org/rfc/rfc7946#section-3.1).
113 The coordinates are assumed to be in the projection order, unless ``always_xy`` is ``True``.
115 Args:
116 geojson: A GeoJSON geometry dict
117 crs: A Crs object.
118 always_xy: If ``True``, coordinates are assumed to be in the XY (lon/lat) order
120 Returns:
121 A Shape object.
122 """
124 geom = _shapely_shape(geojson)
125 if crs.isYX and not always_xy:
126 geom = _swap_xy(geom)
127 return Shape(geom, crs)
130def from_props(props: gws.Props) -> gws.Shape:
131 """Creates a Shape from a properties object.
133 Args:
134 props: A properties object.
135 Returns:
136 A Shape object.
137 """
139 crs = gws.lib.crs.get(props.get('crs'))
140 if not crs:
141 raise Error('missing or invalid crs')
142 geom = _shapely_shape(props.get('geometry'))
143 return Shape(geom, crs)
146def from_dict(d: dict) -> gws.Shape:
147 """Creates a Shape from a dictionary.
149 Args:
150 d: A dictionary with the keys 'crs' and 'geometry'.
151 Returns:
152 A Shape object.
153 """
155 crs = gws.lib.crs.get(d.get('crs'))
156 if not crs:
157 raise Error('missing or invalid crs')
158 geom = _shapely_shape(d.get('geometry'))
159 return Shape(geom, crs)
162def from_extent(extent: gws.Extent, crs: gws.Crs, always_xy=False) -> gws.Shape:
163 """Creates a polygon Shape from an extent.
165 Args:
166 extent: A hex-encoded WKB byte string.
167 crs: A Crs object.
168 always_xy: If ``True``, coordinates are assumed to be in the XY (lon/lat) order
170 Returns:
171 A Shape object.
172 """
174 geom = shapely.geometry.box(*extent)
175 if crs.isYX and not always_xy:
176 geom = _swap_xy(geom)
177 return Shape(geom, crs)
180def from_bounds(bounds: gws.Bounds) -> gws.Shape:
181 """Creates a polygon Shape from a Bounds object.
183 Args:
184 bounds: A Bounds object.
186 Returns:
187 A Shape object.
188 """
190 return Shape(shapely.geometry.box(*bounds.extent), bounds.crs)
193def from_xy(x: float, y: float, crs: gws.Crs) -> gws.Shape:
194 """Creates a point Shape from coordinates.
196 Args:
197 x: X coordinate (lon/easting)
198 y: Y coordinate (lat/northing)
199 crs: A Crs object.
201 Returns:
202 A Shape object.
203 """
205 return Shape(shapely.geometry.Point(x, y), crs)
208def _swap_xy(geom):
209 def f(x: float, y: float, z: float = None) -> tuple[float, float]:
210 return y, x
212 return shapely.ops.transform(f, geom)
215_CIRCLE_RESOLUTION = 64
218def _shapely_shape(d):
219 if d.get('type').upper() == 'CIRCLE':
220 geom = shapely.geometry.Point(d.get('center'))
221 return geom.buffer(
222 d.get('radius'),
223 resolution=_CIRCLE_RESOLUTION,
224 cap_style=shapely.geometry.CAP_STYLE.round,
225 join_style=shapely.geometry.JOIN_STYLE.round,
226 )
228 return shapely.geometry.shape(d)
231##
234class Props(gws.Props):
235 """Shape properties object."""
237 crs: str
238 geometry: dict
241##
244class Shape(gws.Shape):
245 geom: shapely.geometry.base.BaseGeometry
247 def __init__(self, geom, crs: gws.Crs):
248 super().__init__()
249 self.geom = geom
250 self.crs = crs
251 self.type = self.geom.geom_type.lower()
252 self.x = getattr(self.geom, 'x', None)
253 self.y = getattr(self.geom, 'y', None)
255 def __str__(self):
256 return '{Geometry:' + self.geom.geom_type.upper() + '}'
258 def area(self):
259 return getattr(self.geom, 'area', 0)
261 def bounds(self):
262 return gws.Bounds(crs=self.crs, extent=self.geom.bounds)
264 def centroid(self):
265 return Shape(self.geom.centroid, self.crs)
267 def to_wkb(self):
268 return shapely.wkb.dumps(self.geom)
270 def to_wkb_hex(self):
271 return shapely.wkb.dumps(self.geom, hex=True)
273 def to_ewkb(self):
274 return shapely.wkb.dumps(self.geom, srid=self.crs.srid)
276 def to_ewkb_hex(self):
277 return shapely.wkb.dumps(self.geom, srid=self.crs.srid, hex=True)
279 def to_wkt(self, trim=False, rounding_precision=-1, output_dimension=3):
280 s = shapely.wkt.dumps(self.geom, trim=trim, rounding_precision=rounding_precision, output_dimension=output_dimension)
281 s = re.sub(r'\s*([,()])\s*', r'\1', s)
282 s = re.sub(r'\s+', ' ', s.strip())
283 return s
285 def to_ewkt(self, trim=False, rounding_precision=-1, output_dimension=3):
286 return f'SRID={self.crs.srid};' + self.to_wkt(trim=trim, rounding_precision=rounding_precision, output_dimension=output_dimension)
288 def to_geojson(self, keep_crs=False):
289 # see https://datatracker.ietf.org/doc/html/rfc7946#section-4
290 # convert to WGS lon,lat unless keep_crs is true
291 # coords order is always XY
293 if keep_crs or self.crs == gws.lib.crs.WGS84:
294 return shapely.geometry.mapping(self.geom)
296 tr = self.crs.transformer(gws.lib.crs.WGS84)
297 new_geom = shapely.ops.transform(tr, self.geom)
298 return shapely.geometry.mapping(new_geom)
300 def to_props(self):
301 return gws.ShapeProps(crs=self.crs.epsg, geometry=shapely.geometry.mapping(self.geom))
303 def is_empty(self):
304 return self.geom.is_empty
306 def is_ring(self):
307 return self.geom.is_ring
309 def is_simple(self):
310 return self.geom.is_simple
312 def is_valid(self):
313 return self.geom.is_valid
315 def equals(self, other):
316 return self._binary_predicate(other, 'equals')
318 def contains(self, other):
319 return self._binary_predicate(other, 'contains')
321 def covers(self, other):
322 return self._binary_predicate(other, 'covers')
324 def covered_by(self, other):
325 return self._binary_predicate(other, 'covered_by')
327 def crosses(self, other):
328 return self._binary_predicate(other, 'crosses')
330 def disjoint(self, other):
331 return self._binary_predicate(other, 'disjoint')
333 def intersects(self, other):
334 return self._binary_predicate(other, 'intersects')
336 def overlaps(self, other):
337 return self._binary_predicate(other, 'overlaps')
339 def touches(self, other):
340 return self._binary_predicate(other, 'touches')
342 def within(self, other):
343 return self._binary_predicate(other, 'within')
345 def _binary_predicate(self, other, op):
346 s = other.transformed_to(self.crs)
347 return getattr(self.geom, op)(getattr(s, 'geom'))
349 def union(self, others):
350 if not others:
351 return self
353 geoms = [self.geom]
354 for s in others:
355 s = s.transformed_to(self.crs)
356 geoms.append(getattr(s, 'geom'))
358 geom = shapely.ops.unary_union(geoms)
359 return Shape(geom, self.crs)
361 def intersection(self, *others):
362 if not others:
363 return self
365 geom = self.geom
366 for s in others:
367 s = s.transformed_to(self.crs)
368 geom = geom.intersection(getattr(s, 'geom'))
370 return Shape(geom, self.crs)
372 def to_multi(self):
373 if self.type == gws.GeometryType.point:
374 return Shape(shapely.geometry.MultiPoint([self.geom]), self.crs)
375 if self.type == gws.GeometryType.linestring:
376 return Shape(shapely.geometry.MultiLineString([self.geom]), self.crs)
377 if self.type == gws.GeometryType.polygon:
378 return Shape(shapely.geometry.MultiPolygon([self.geom]), self.crs)
379 return self
381 def to_type(self, new_type: gws.GeometryType):
382 if new_type == self.type:
383 return self
384 if new_type == gws.GeometryType.geometry:
385 return self
386 if self.type == gws.GeometryType.point and new_type == gws.GeometryType.multipoint:
387 return self.to_multi()
388 if self.type == gws.GeometryType.linestring and new_type == gws.GeometryType.multilinestring:
389 return self.to_multi()
390 if self.type == gws.GeometryType.polygon and new_type == gws.GeometryType.multipolygon:
391 return self.to_multi()
392 raise Error(f'cannot convert {self.type!r} to {new_type!r}')
394 def to_2d(self):
395 geom = shapely.force_2d(self.geom)
396 if geom is self.geom:
397 return self
398 return Shape(geom, self.crs)
400 def tolerance_polygon(self, tolerance=None, quad_segs=None):
401 is_poly = self.type in (gws.GeometryType.polygon, gws.GeometryType.multipolygon)
403 if not tolerance and is_poly:
404 return self
406 # we need a polygon even if tolerance = 0
407 tolerance = tolerance or _MIN_TOLERANCE_RADIUS
408 quad_segs = quad_segs or _TOLERANCE_QUAD_SEGS
410 geom = self.geom
411 if self.crs.isGeographic:
412 tr = self.crs.transformer(gws.lib.crs.WEBMERCATOR)
413 geom = shapely.ops.transform(tr, self.geom)
415 if is_poly:
416 cs = shapely.geometry.CAP_STYLE.flat
417 js = shapely.geometry.JOIN_STYLE.mitre
418 else:
419 cs = shapely.geometry.CAP_STYLE.round
420 js = shapely.geometry.JOIN_STYLE.round
422 geom = geom.buffer(tolerance, quad_segs, cap_style=cs, join_style=js)
423 if self.crs.isGeographic:
424 tr = gws.lib.crs.WEBMERCATOR.transformer(self.crs)
425 geom = shapely.ops.transform(tr, geom)
427 return Shape(geom, self.crs)
429 def transformed_to(self, crs):
430 if crs == self.crs:
431 return self
432 tr = self.crs.transformer(crs)
433 dg = shapely.ops.transform(tr, self.geom)
434 return Shape(dg, crs)