Source code for gpf.tools.geometry

# coding: utf-8
#
# Copyright 2019 Geocom Informatik AG / VertiGIS

# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at

#     http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
The *geometry* module contains functions that help working with Esri geometries.
"""

import typing as _tp

import more_itertools as _iter

import gpf.common.textutils as _tu
import gpf.common.validate as _vld
from gpf import arcpy as _arcpy


[docs]class GeometryError(ValueError): """ If the :class:`ShapeBuilder` cannot create the desired output geometry, a GeometryError is raised. """ pass
[docs]class ShapeBuilder: """ Helper class to create Esri geometry objects from arcpy ``Point`` or ``Array`` objects or coordinate values. Examples: >>> # instantiate a 2D PointGeometry >>> ShapeBuilder(6.5, 2.8).as_point() <PointGeometry object at 0x19fcbdf0[0x19fcbd80]> >>> # instantiate a 3D PointGeometry >>> ShapeBuilder(6.5, 2.8, 5.3).as_point(has_z=True) <PointGeometry object at 0x6b96210[0x19fcbbe0]> >>> # make_path a 2D line (append technique) >>> shp = ShapeBuilder() >>> shp.append(1.0, 2.0) >>> shp.append(1.5, 3.0) >>> shp.as_polyline() <Polyline object at 0x6a9bb70[0x6fe2540]> >>> # make_path a 3D polygon from 2D coordinates >>> shp = ShapeBuilder([(1.0, 2.0), (1.5, 3.0), (2.0, 2.0)]) >>> polygon = shp.as_polygon(has_z=True) >>> # Z values are added and set to 0 >>> polygon.firstPoint <Point (1.00012207031, 2.00012207031, 0.0, #)> >>> # note that the "open" polygons will be closed automatically >>> polygon.firstPoint == polygon.lastPoint True >>> # calling as_point() on a ShapeBuilder with multiple coordinates will return a centroid >>> shp.as_point() <Point (1.50012207031, 2.33345540365, #, #)> """ __slots__ = '_arr', '_num_coords' def __init__(self, *args): # Because Array is an ArcObject, we cannot inherit from it the way we'd like to (raises RuntimeError). # We'll instantiate a new Array and store it in its own variable instead... self._arr = _arcpy.Array() self._num_coords = 0 if args: try: self.extend(_iter.first(args)) except ValueError: self.append(*args) def __iter__(self): return iter(self._arr) def __len__(self): return len(self._arr)
[docs] def append(self, *args): """ Adds a coordinate or coordinate array to the geometry. Valid objects are another ``ShapeBuilder`` instance, an ArcPy ``Point`` or ``Array`` instance, or numeric X, Y, (Z, M, ID) values. :param args: A valid coordinate object. :type args: float, int, arcpy.Point, arcpy.Array, ShapeBuilder :raises ValueError: If the coordinate object is invalid and cannot be added. .. seealso:: https://desktop.arcgis.com/en/arcmap/latest/analyze/arcpy-classes/point.htm """ value = tuple(_iter.collapse(args, levels=1)) try: if len(value) == 1: # User can add Point, Array or ShapeBuilder objects coord = _iter.first(value) if isinstance(coord, (ShapeBuilder, _arcpy.Array)): self._arr.append(coord) self._num_coords += coord.num_coords if hasattr(coord, 'num_coords') else len(coord) return elif 2 <= len(value) <= 5: # User can add up to 5 values (X, Y, Z, M, ID) coord = _arcpy.Point(*value) else: raise ValueError('Cannot add coordinate object {}'.format(_tu.to_repr(value))) self._arr.append(coord) self._num_coords += 1 except (RuntimeError, ValueError) as e: # User tried to add something invalid raise ValueError(e)
[docs] def extend(self, values: _tp.Iterable): """ Adds multiple coordinates to the geometry. :param values: An iterable of numeric coordinate values, ``Point``, ``Array`` or ``ShapeBuilder`` objects. :type values: tuple, list :raises ValueError: If the *values* argument is not an iterable. """ _vld.pass_if(_vld.is_iterable(values), ValueError, 'extend() expects an iterable') for v in values: self.append(v)
@staticmethod def _output(shape_type, coords, spatial_reference, has_z: bool, has_m: bool): """ Outputs the stored geometry array as the specified type. """ try: return shape_type(coords, spatial_reference, has_z, has_m) except Exception as e: raise GeometryError(e) @property def num_coords(self) -> int: """ Returns the total number of coordinates in the ShapeBuilder. Note that this does not always return the same value as calling :func:`len` on the ShapeBuilder, because :func:`num_coords` also counts the coordinates in nested geometry arrays. """ return self._num_coords
[docs] def as_point(self, spatial_reference: _tp.Union[str, int, _arcpy.SpatialReference, None] = None, has_z: bool = False, has_m: bool = False) -> _arcpy.PointGeometry: """ Returns the constructed geometry as an Esri ``PointGeometry``. Note that if the ShapeBuilder holds more than 1 coordinate, a centroid point is returned. :param spatial_reference: An optional spatial reference. Defaults to 'Unknown'. :param has_z: If ``True``, the geometry is Z aware. Defaults to ``False``. :param has_m: If ``True``, the geometry is M aware. Defaults to ``False``. :type spatial_reference: str, int, arcpy.SpatialReference :raises GeometryError: If there is less than 1 coordinate. """ _vld.pass_if(self.num_coords >= 1, GeometryError, 'PointGeometry must have at least 1 coordinate') if self.num_coords == 1: return self._output(_arcpy.PointGeometry, self._arr[0], spatial_reference, has_z, has_m) else: return self._output(_arcpy.Multipoint, self._arr, spatial_reference, has_z, has_m).centroid
[docs] def as_multipoint(self, spatial_reference: _tp.Union[str, int, _arcpy.SpatialReference, None] = None, has_z: bool = False, has_m: bool = False) -> _arcpy.Multipoint: """ Returns the constructed geometry as an Esri ``Multipoint``. :param spatial_reference: An optional spatial reference. Defaults to 'Unknown'. :param has_z: If ``True``, the geometry is Z aware. Defaults to ``False``. :param has_m: If ``True``, the geometry is M aware. Defaults to ``False``. :type spatial_reference: str, int, arcpy.SpatialReference :raises GeometryError: If there are less than 2 coordinates. """ _vld.pass_if(self.num_coords >= 2, GeometryError, 'Multipoint must have at least 2 coordinates') return self._output(_arcpy.Multipoint, self._arr, spatial_reference, has_z, has_m)
[docs] def as_polyline(self, spatial_reference: _tp.Union[str, int, _arcpy.SpatialReference, None] = None, has_z: bool = False, has_m: bool = False) -> _arcpy.Polyline: """ Returns the constructed geometry as an Esri ``Polyline``. :param spatial_reference: An optional spatial reference. Defaults to 'Unknown'. :param has_z: If ``True``, the geometry is Z aware. Defaults to ``False``. :param has_m: If ``True``, the geometry is M aware. Defaults to ``False``. :type spatial_reference: str, int, arcpy.SpatialReference :raises GeometryError: If there are less than 2 coordinates. """ _vld.pass_if(self.num_coords >= 2, GeometryError, 'Polyline must have at least 2 coordinates') return self._output(_arcpy.Polyline, self._arr, spatial_reference, has_z, has_m)
[docs] def as_polygon(self, spatial_reference: _tp.Union[str, int, _arcpy.SpatialReference, None] = None, has_z: bool = False, has_m: bool = False) -> _arcpy.Polygon: """ Returns the constructed geometry as an Esri ``Polygon``. If the polygon is not closed, the first coordinate will be added as the last coordinate automatically in order to properly close it. :param spatial_reference: An optional spatial reference. Defaults to 'Unknown'. :param has_z: If ``True``, the geometry is Z aware. Defaults to ``False``. :param has_m: If ``True``, the geometry is M aware. Defaults to ``False``. :raises ValueError: If there are less than 3 coordinates. """ _vld.pass_if(self.num_coords >= 3, GeometryError, 'Polygon must have at least 3 coordinates') coords = self._arr if self.num_coords == 3: # Use a copy of the current array and append the first point to close the polygon coords = _arcpy.Array(c for c in self._arr) coords.append(self._arr[0]) return self._output(_arcpy.Polygon, coords, spatial_reference, has_z, has_m)
def _fix_coord(*args, **kwargs) -> _tp.Generator: """ Returns a generator of *dim* numbers (default = 2), where *dim* is the number of dimensions. For every value in *args* that is missing, a value of ``None`` will be yielded. For example, if a coordinate tuple with 2 arguments was passed in, but the expected number of dimensions is 3, the generator will return 3 values, of which the last one is ``None``. """ dim = kwargs.get('dim', 2) for i in range(dim): try: yield args[i] except IndexError: yield None
[docs]def get_xyz(*args) -> _tp.Tuple[float]: """ Returns a floating point coordinate XYZ tuple for a given coordinate. Valid input includes EsriJSON, ArcPy Point or PointGeometry instances or a minimum of 2 floating point values. If the geometry is not Z aware, the Z value in the output tuple will be set to ``None``. :param args: A tuple of floating point values, an EsriJSON dictionary, an ArcPy Point or PointGeometry instance. .. note:: For Point geometries, M and ID values are ignored. """ p_args = args if len(args) == 1: a = _iter.first(args) # Unfortunately, we can't really rely on isinstance() to check if it's a PointGeometry or Point. # However, if it's a PointGeometry, it must have a pointCount attribute with a value of 1. if getattr(a, 'pointCount', 0) == 1: # Get first Point from PointGeometry... a = a.firstPoint if hasattr(a, 'X') and hasattr(a, 'Y'): # Get X, Y and Z properties from Point p_args = a.X, a.Y, a.Z elif isinstance(a, dict): # Assume argument is JSON(-like) input: read x, y and z keys p_args = tuple(v for k, v in sorted(a.items()) if k.lower() in ('x', 'y', 'z')) # Validate values for a in p_args: _vld.pass_if(_vld.is_number(a), ValueError, 'Failed to parse coordinate from JSON'.format(args)) else: raise ValueError('Input is not a Point, PointGeometry, JSON dictionary or iterable of float') return tuple(_fix_coord(*p_args, dim=3))
[docs]def get_vertices(geometry) -> _tp.Generator: """ Returns a generator of coordinate tuples (x, y[, z] floats) for all vertices in an Esri Geometry. If the geometry is not Z aware, the coordinate tuples will only hold 2 values (X and Y). :param geometry: The Esri Geometry (e.g. Polygon, Polyline etc.) for which to extract all vertices. """ if _vld.is_iterable(geometry): _vld.pass_if(isinstance(geometry, (_arcpy.Geometry, _arcpy.Array)), ValueError, 'get_vertices() requires an Esri Geometry or Array') for g in geometry: for v in get_vertices(g): yield v else: yield tuple(v for v in get_xyz(geometry) if v)