# Copyright (c) 2020-2024, Manfred Moitzi # License: MIT License """ Implementation of the `__geo_interface__`: https://gist.github.com/sgillies/2217756 Which is also supported by Shapely: https://pypi.org/project/Shapely/ Type definitions see GeoJson Standard: https://tools.ietf.org/html/rfc7946 and examples : https://tools.ietf.org/html/rfc7946#appendix-A GeoJSON Linter: https://geojsonlint.com/ """ from __future__ import annotations from typing import ( Iterable, Iterator, Union, cast, Callable, Sequence, Optional, Any, MutableMapping, ) from typing_extensions import TypeAlias, Self import numbers import copy import math import enum from ezdxf.math import ( Vec3, has_clockwise_orientation, Matrix44, world_mercator_to_gps, gps_to_world_mercator, ) from ezdxf.path import make_path, from_hatch_boundary_path, make_polygon_structure from ezdxf.entities import DXFGraphic, LWPolyline, Point, Polyline, Line from ezdxf.entities.polygon import DXFPolygon from ezdxf.lldxf import const from ezdxf.entities import factory __all__ = ["proxy", "dxf_entities", "gfilter", "GeoProxy", "PolygonConversion"] TYPE = "type" COORDINATES = "coordinates" POINT = "Point" MULTI_POINT = "MultiPoint" LINE_STRING = "LineString" MULTI_LINE_STRING = "MultiLineString" POLYGON = "Polygon" MULTI_POLYGON = "MultiPolygon" GEOMETRY_COLLECTION = "GeometryCollection" GEOMETRIES = "geometries" GEOMETRY = "geometry" FEATURES = "features" FEATURE = "Feature" PROPERTIES = "properties" FEATURE_COLLECTION = "FeatureCollection" MAX_FLATTENING_DISTANCE = 0.1 SUPPORTED_DXF_TYPES = { "POINT", "LINE", "LWPOLYLINE", "POLYLINE", "HATCH", "MPOLYGON", "SOLID", "TRACE", "3DFACE", "CIRCLE", "ARC", "ELLIPSE", "SPLINE", } GeoMapping: TypeAlias = MutableMapping[str, Any] PostProcessFunc: TypeAlias = Callable[[DXFGraphic, GeoMapping], None] class PolygonConversion(enum.IntEnum): """Polygon conversion types as :class:`IntEnum`. Attributes: HATCH: POLYLINE: HATCH_AND_POLYLINE: MPOLYGON: """ HATCH = 1 POLYLINE = 2 HATCH_AND_POLYLINE = 3 MPOLYGON = 4 def proxy( entity: Union[DXFGraphic, Iterable[DXFGraphic]], distance: float = MAX_FLATTENING_DISTANCE, force_line_string: bool = False, ) -> GeoProxy: """Returns a :class:`GeoProxy` object. Args: entity: a single DXF entity or iterable of DXF entities distance: maximum flattening distance for curve approximations force_line_string: by default this function returns Polygon objects for closed geometries like CIRCLE, SOLID, closed POLYLINE and so on, by setting argument `force_line_string` to ``True``, this entities will be returned as LineString objects. """ return GeoProxy.from_dxf_entities(entity, distance, force_line_string) def dxf_entities( geo_mapping: GeoMapping, polygon=PolygonConversion.HATCH, dxfattribs=None, *, post_process: Optional[PostProcessFunc] = None, ) -> Iterator[DXFGraphic]: """Returns ``__geo_interface__`` mappings as DXF entities. The enum `polygon` determines the method to convert polygons, use :attr:`PolygonConversion.HATCH` for :class:`~ezdxf.entities.Hatch` entity, :attr:`PolygonConversion.POLYLINE` for :class:`~ezdxf.entities.LWPolyline` or :attr:`PolygonConversion.HATCH_AND_POLYLINE` for both. Option :attr:`PolygonConversion.POLYLINE` returns for the exterior path and each hole a separated :class:`LWPolyline` entity. The :class:`Hatch` entity supports holes, but has no explicit borderline. Yields :class:`Hatch` always before :class:`LWPolyline` entities. :attr:`PolygonConversion.MPOLYGON` support was added in v0.16.6, which is like a :class:`~ezdxf.entities.Hatch` entity with additional borderlines, but the MPOLYGON entity is not a core DXF entity and DXF viewers, applications and libraries my not support this entity. The DXF attribute `color` defines the borderline color and `fill_color` the color of the solid filling. The returned DXF entities can be added to a layout by the :meth:`Layout.add_entity` method. Args: geo_mapping: ``__geo__interface__`` mapping as :class:`dict` or a Python object with a :attr:`__geo__interface__` property polygon: see :class:`PolygonConversion` dxfattribs: dict with additional DXF attributes post_process: post process function of type :class:`PostProcessFunc` that get the created DXF entity and the geo mapping as input, see reference implementation :func:`assign_layers` """ return GeoProxy.parse(geo_mapping).to_dxf_entities( polygon, dxfattribs, post_process=post_process ) def gfilter(entities: Iterable[DXFGraphic]) -> Iterator[DXFGraphic]: """Filter DXF entities from iterable `entities`, which are incompatible to the ``__geo_reference__`` interface. """ for e in entities: if isinstance(e, Polyline): if e.is_2d_polyline or e.is_3d_polyline: yield e elif e.dxftype() in SUPPORTED_DXF_TYPES: yield e TFunc: TypeAlias = Callable[[Vec3], Vec3] class GeoProxy: """Stores the ``__geo_interface__`` mapping in a parsed and compiled form. Stores coordinates as :class:`Vec3` objects and represents "Polygon" always as tuple (exterior, holes) even without holes. The GeoJSON specification recommends 6 decimal places for latitude and longitude which equates to roughly 10cm of precision. You may need slightly more for certain applications, 9 decimal places would be sufficient for professional survey-grade GPS coordinates. Args: geo_mapping: parsed and compiled ``__geo_interface__`` mapping places: decimal places to round for ``__geo_interface__`` export """ def __init__(self, geo_mapping: GeoMapping, places: int = 6): self._root = geo_mapping self.places = places @classmethod def parse(cls, geo_mapping: GeoMapping) -> Self: """Parse and compile a ``__geo_interface__`` mapping as :class:`dict` or a Python object with a ``__geo_interface__`` property, does some basic syntax checks, converts all coordinates into :class:`Vec3` objects, represents "Polygon" always as tuple (exterior, holes) even without holes. """ if hasattr(geo_mapping, "__geo_interface__"): geo_mapping = geo_mapping.__geo_interface__ return cls(parse(geo_mapping)) @property def root(self) -> GeoMapping: return self._root @property def geotype(self): """Property returns the top level entity type or ``None``.""" return self._root.get("type") def __copy__(self) -> GeoProxy: """Returns a deep copy.""" return copy.deepcopy(self) copy = __copy__ @property def __geo_interface__(self) -> GeoMapping: """Returns the ``__geo_interface__`` compatible mapping as :class:`dict`. """ return _rebuild(self._root, self.places) def __iter__(self) -> Iterator[GeoMapping]: """Iterate over all geometry entities. Yields only "Point", "LineString", "Polygon", "MultiPoint", "MultiLineString" and "MultiPolygon" objects, returns the content of "GeometryCollection", "FeatureCollection" and "Feature" as geometry objects ("Point", ...). """ def _iter(node: GeoMapping) -> Iterator[GeoMapping]: type_ = node[TYPE] if type_ == FEATURE_COLLECTION: for feature in node[FEATURES]: yield from _iter(feature) elif type_ == GEOMETRY_COLLECTION: for geometry in node[GEOMETRIES]: yield from _iter(geometry) elif type_ == FEATURE: geometry = node[GEOMETRY] if geometry[TYPE] == GEOMETRY_COLLECTION: yield from _iter(geometry) else: yield geometry else: yield node yield from _iter(self._root) def filter(self, func: Callable[[GeoProxy], bool]) -> None: """Removes all mappings for which `func()` returns ``False``. The function only has to handle Point, LineString and Polygon entities, other entities like MultiPolygon are divided into separate entities also any collection. """ def multi_entity(root, type_) -> bool: coordinates = [] for entity in root[COORDINATES]: if func(GeoProxy({TYPE: type_, COORDINATES: entity})): coordinates.append(entity) root[COORDINATES] = coordinates return bool(len(coordinates)) def check(root) -> bool: type_ = root[TYPE] if type_ == FEATURE_COLLECTION: root[FEATURES] = [ feature for feature in root[FEATURES] if check(feature) ] return bool(len(root[FEATURES])) elif type_ == GEOMETRY_COLLECTION: root[GEOMETRIES] = [ geometry for geometry in root[GEOMETRIES] if check(geometry) ] return bool(len(root[GEOMETRIES])) elif type_ == FEATURE: root[GEOMETRY] = root[GEOMETRY] if check(root[GEOMETRY]) else {} return bool(root[GEOMETRY]) elif type_ == MULTI_POINT: return multi_entity(root, POINT) elif type_ == MULTI_LINE_STRING: return multi_entity(root, LINE_STRING) elif type_ == MULTI_POLYGON: return multi_entity(root, POLYGON) else: return func(GeoProxy(root)) if not check(self._root): self._root = {} def globe_to_map(self, func: Optional[TFunc] = None) -> None: """Transform all coordinates recursive from globe representation in longitude and latitude in decimal degrees into 2D map representation in meters. Default is WGS84 `EPSG:4326 `_ (GPS) to WGS84 `EPSG:3395 `_ World Mercator function :func:`wgs84_4326_to_3395`. Use the `pyproj `_ package to write a custom projection function as needed. Args: func: custom transformation function, which takes one :class:`Vec3` object as argument and returns the result as a :class:`Vec3` object. """ if func is None: func = wgs84_4326_to_3395 self.apply(func) def map_to_globe(self, func: Optional[TFunc] = None) -> None: """Transform all coordinates recursive from 2D map representation in meters into globe representation as longitude and latitude in decimal degrees. Default is WGS84 `EPSG:3395 `_ World Mercator to WGS84 `EPSG:4326 `_ GPS function :func:`wgs84_3395_to_4326`. Use the `pyproj `_ package to write a custom projection function as needed. Args: func: custom transformation function, which takes one :class:`Vec3` object as argument and returns the result as a :class:`Vec3` object. """ if func is None: func = wgs84_3395_to_4326 self.apply(func) def crs_to_wcs(self, crs: Matrix44) -> None: """Transform all coordinates recursive from CRS into :ref:`WCS` coordinates by transformation matrix `crs` inplace, see also :meth:`GeoProxy.wcs_to_crs`. Args: crs: transformation matrix of type :class:`~ezdxf.math.Matrix44` """ self.apply(crs.ucs_vertex_from_wcs) def wcs_to_crs(self, crs: Matrix44) -> None: """Transform all coordinates recursive from :ref:`WCS` coordinates into Coordinate Reference System (CRS) by transformation matrix `crs` inplace. The CRS is defined by the :class:`~ezdxf.entities.GeoData` entity, get the :class:`GeoData` entity from the modelspace by method :meth:`~ezdxf.layouts.Modelspace.get_geodata`. The CRS transformation matrix can be acquired form the :class:`GeoData` object by :meth:`~ezdxf.entities.GeoData.get_crs_transformation` method: .. code:: Python doc = ezdxf.readfile('file.dxf') msp = doc.modelspace() geodata = msp.get_geodata() if geodata: matrix, axis_ordering = geodata.get_crs_transformation() If `axis_ordering` is ``False`` the CRS is not compatible with the ``__geo_interface__`` or GeoJSON (see chapter 3.1.1). Args: crs: transformation matrix of type :class:`~ezdxf.math.Matrix44` """ self.apply(crs.transform) def apply(self, func: TFunc) -> None: """Apply the transformation function `func` recursive to all coordinates. Args: func: transformation function as Callable[[Vec3], Vec3] """ def process(entity: GeoMapping): def transform(coords): if isinstance(coords, Vec3): return func(coords) else: return [transform(c) for c in coords] entity[COORDINATES] = transform(entity[COORDINATES]) for entity in self.__iter__(): process(entity) @classmethod def from_dxf_entities( cls, entity: Union[DXFGraphic, Iterable[DXFGraphic]], distance: float = MAX_FLATTENING_DISTANCE, force_line_string: bool = False, ) -> GeoProxy: """Constructor from a single DXF entity or an iterable of DXF entities. Args: entity: DXF entity or entities distance: maximum flattening distance for curve approximations force_line_string: by default this function returns Polygon objects for closed geometries like CIRCLE, SOLID, closed POLYLINE and so on, by setting argument `force_line_string` to ``True``, this entities will be returned as LineString objects. """ if isinstance(entity, DXFGraphic): m = mapping(entity, distance, force_line_string) else: m = collection(entity, distance, force_line_string) return cls(m) def to_dxf_entities( self, polygon=PolygonConversion.HATCH, dxfattribs=None, *, post_process: Optional[PostProcessFunc] = None, ) -> Iterator[DXFGraphic]: """Returns stored ``__geo_interface__`` mappings as DXF entities. The `polygon` argument determines the method to convert polygons, use 1 for :class:`~ezdxf.entities.Hatch` entity, 2 for :class:`~ezdxf.entities.LWPolyline` or 3 for both. Option 2 returns for the exterior path and each hole a separated :class:`LWPolyline` entity. The :class:`Hatch` entity supports holes, but has no explicit borderline. Yields :class:`Hatch` always before :class:`LWPolyline` entities. :class:`~ezdxf.entities.MPolygon` support was added in v0.16.6, which is like a :class:`~ezdxf.entities.Hatch` entity with additional borderlines, but the MPOLYGON entity is not a core DXF entity and DXF viewers, applications and libraries my not support this entity. The DXF attribute `color` defines the borderline color and `fill_color` the color of the solid filling. The returned DXF entities can be added to a layout by the :meth:`Layout.add_entity` method. Args: polygon: see :class:`PolygonConversion` dxfattribs: dict with additional DXF attributes post_process: post process function of type :class:`PostProcesFunc` that get the created DXF entity and the geo mapping as input, see reference implementation :func:`assign_layers` """ def point(vertex: Sequence) -> Point: point = cast(Point, factory.new("POINT", dxfattribs=dxfattribs)) point.dxf.location = vertex return point def lwpolyline(vertices: Sequence) -> LWPolyline: polyline = cast( LWPolyline, factory.new("LWPOLYLINE", dxfattribs=dxfattribs) ) polyline.append_points(vertices, format="xy") return polyline def polygon_(exterior: list, holes: list) -> Iterator[DXFGraphic]: if polygon == PolygonConversion.MPOLYGON: yield mpolygon_(exterior, holes) # the following DXF entities do not support the # "fill_color" attribute return if polygon & PolygonConversion.HATCH: yield hatch_(exterior, holes) if polygon & PolygonConversion.POLYLINE: for path in [exterior] + holes: yield lwpolyline(path) def dxf_polygon_( dxftype: str, exterior: Sequence, holes: Sequence ) -> DXFPolygon: dxf_polygon = cast(DXFPolygon, factory.new(dxftype, dxfattribs=dxfattribs)) dxf_polygon.dxf.hatch_style = const.HATCH_STYLE_OUTERMOST dxf_polygon.paths.add_polyline_path( exterior, flags=const.BOUNDARY_PATH_EXTERNAL ) for hole in holes: dxf_polygon.paths.add_polyline_path( hole, flags=const.BOUNDARY_PATH_OUTERMOST ) return dxf_polygon def hatch_(exterior: Sequence, holes: Sequence) -> DXFPolygon: return dxf_polygon_("HATCH", exterior, holes) def mpolygon_(exterior: Sequence, holes: Sequence) -> DXFPolygon: return dxf_polygon_("MPOLYGON", exterior, holes) def entity(type_, coordinates) -> Iterator[DXFGraphic]: if type_ == POINT: yield point(coordinates) elif type_ == LINE_STRING: yield lwpolyline(coordinates) elif type_ == POLYGON: exterior, holes = coordinates yield from polygon_(exterior, holes) elif type_ == MULTI_POINT: for data in coordinates: yield point(data) elif type_ == MULTI_LINE_STRING: for data in coordinates: yield lwpolyline(data) elif type_ == MULTI_POLYGON: for data in coordinates: exterior, holes = data yield from polygon_(exterior, holes) if polygon < 1 or polygon > 4: raise ValueError(f"invalid value for polygon: {polygon}") dxfattribs = dict(dxfattribs or {}) for feature, geometry in iter_features(self._root): type_ = geometry.get(TYPE) for e in entity(type_, geometry.get(COORDINATES)): if post_process: post_process(e, feature) yield e def iter_features(geo_mapping: GeoMapping) -> Iterator[tuple[GeoMapping, GeoMapping]]: """Yields all geometries of a ``__geo_mapping__`` as (`feature`, `geometry`) tuples. If no feature is defined the `feature` value is an empty ``dict``. When a `feature` contains `GeometryCollections`, the function yields for each sub-geometry a separate (`feature`, `geometry`) tuple. """ current_feature: GeoMapping = {} def features(node: GeoMapping) -> Iterator[tuple[GeoMapping, GeoMapping]]: nonlocal current_feature type_ = node[TYPE] if type_ == FEATURE_COLLECTION: for feature in node[FEATURES]: yield from features(feature) elif type_ == GEOMETRY_COLLECTION: for geometry in node[GEOMETRIES]: yield from features(geometry) elif type_ == FEATURE: current_feature = node geometry = node[GEOMETRY] if geometry[TYPE] == GEOMETRY_COLLECTION: yield from features(geometry) else: yield current_feature, geometry else: yield current_feature, node yield from features(geo_mapping) def parse(geo_mapping: GeoMapping) -> GeoMapping: """Parse ``__geo_interface__`` convert all coordinates into :class:`Vec3` objects, Polygon['coordinates'] is always a tuple (exterior, holes), holes maybe an empty list. """ geo_mapping = copy.deepcopy(geo_mapping) type_ = geo_mapping.get(TYPE) if type_ is None: raise ValueError(f'Required key "{TYPE}" not found.') if type_ == FEATURE_COLLECTION: # It is possible for this array to be empty. features = geo_mapping.get(FEATURES) if features: geo_mapping[FEATURES] = [parse(f) for f in features] else: raise ValueError(f'Missing key "{FEATURES}" in FeatureCollection.') elif type_ == GEOMETRY_COLLECTION: # It is possible for this array to be empty. geometries = geo_mapping.get(GEOMETRIES) if geometries: geo_mapping[GEOMETRIES] = [parse(g) for g in geometries] else: raise ValueError(f'Missing key "{GEOMETRIES}" in GeometryCollection.') elif type_ == FEATURE: # The value of the geometry member SHALL be either a Geometry object # or, in the case that the Feature is unlocated, a JSON null value. if GEOMETRY in geo_mapping: geometry = geo_mapping.get(GEOMETRY) geo_mapping[GEOMETRY] = parse(geometry) if geometry else None else: raise ValueError(f'Missing key "{GEOMETRY}" in Feature.') elif type_ in { POINT, LINE_STRING, POLYGON, MULTI_POINT, MULTI_LINE_STRING, MULTI_POLYGON, }: coordinates = geo_mapping.get(COORDINATES) if coordinates is None: raise ValueError(f'Missing key "{COORDINATES}" in {type_}.') if type_ == POINT: coordinates = Vec3(coordinates) elif type_ in (LINE_STRING, MULTI_POINT): coordinates = Vec3.list(coordinates) elif type_ == POLYGON: coordinates = _parse_polygon(coordinates) elif type_ == MULTI_LINE_STRING: coordinates = [Vec3.list(v) for v in coordinates] elif type_ == MULTI_POLYGON: coordinates = [_parse_polygon(v) for v in coordinates] geo_mapping[COORDINATES] = coordinates else: raise TypeError(f'Invalid type "{type_}".') return geo_mapping def _is_coordinate_sequence(coordinates: Sequence) -> bool: """Returns ``True`` for a sequence of coordinates like [(0, 0), (1, 0)] and ``False`` for a sequence of sequences: [[(0, 0), (1, 0)], [(2, 0), (3, 0)]] """ if not isinstance(coordinates, Sequence): raise ValueError("Invalid coordinate sequence.") if len(coordinates) == 0: raise ValueError("Invalid coordinate sequence.") first_item = coordinates[0] if len(first_item) == 0: raise ValueError("Invalid coordinate sequence.") return isinstance(first_item[0], numbers.Real) def _parse_polygon(coordinates: Sequence) -> Sequence: """Returns polygon definition as tuple (exterior, [holes]).""" if _is_coordinate_sequence(coordinates): exterior = coordinates holes: Sequence = [] else: exterior = coordinates[0] holes = coordinates[1:] return Vec3.list(exterior), [Vec3.list(h) for h in holes] def _rebuild(geo_mapping: GeoMapping, places: int = 6) -> GeoMapping: """Returns ``__geo_interface__`` compatible mapping as :class:`dict` from compiled internal representation. """ def pnt(v: Vec3) -> tuple[float, float]: return round(v.x, places), round(v.y, places) def _polygon(exterior, holes): # For type "Polygon", the "coordinates" member MUST be an array of # linear ring coordinate arrays. return [[pnt(v) for v in ring] for ring in [exterior] + holes] geo_interface = dict(geo_mapping) type_ = geo_interface[TYPE] if type_ == FEATURE_COLLECTION: geo_interface[FEATURES] = [_rebuild(f) for f in geo_interface[FEATURES]] elif type_ == GEOMETRY_COLLECTION: geo_interface[GEOMETRIES] = [_rebuild(g) for g in geo_interface[GEOMETRIES]] elif type_ == FEATURE: geo_interface[GEOMETRY] = _rebuild(geo_interface[GEOMETRY]) elif type_ == POINT: v = geo_interface[COORDINATES] geo_interface[COORDINATES] = pnt(v) elif type_ in (LINE_STRING, MULTI_POINT): coordinates = geo_interface[COORDINATES] geo_interface[COORDINATES] = [pnt(v) for v in coordinates] elif type_ == MULTI_LINE_STRING: coordinates = [] for line in geo_interface[COORDINATES]: coordinates.append([pnt(v) for v in line]) elif type_ == POLYGON: geo_interface[COORDINATES] = _polygon(*geo_interface[COORDINATES]) elif type_ == MULTI_POLYGON: geo_interface[COORDINATES] = [ _polygon(exterior, holes) for exterior, holes in geo_interface[COORDINATES] ] return geo_interface def mapping( entity: DXFGraphic, distance: float = MAX_FLATTENING_DISTANCE, force_line_string: bool = False, ) -> GeoMapping: """Create the compiled ``__geo_interface__`` mapping as :class:`dict` for the given DXF `entity`, all coordinates are :class:`Vec3` objects and represents "Polygon" always as tuple (exterior, holes) even without holes. Internal API - result is **not** a valid ``_geo_interface__`` mapping! Args: entity: DXF entity distance: maximum flattening distance for curve approximations force_line_string: by default this function returns Polygon objects for closed geometries like CIRCLE, SOLID, closed POLYLINE and so on, by setting argument `force_line_string` to ``True``, this entities will be returned as LineString objects. """ dxftype = entity.dxftype() if isinstance(entity, Point): return {TYPE: POINT, COORDINATES: entity.dxf.location} elif isinstance(entity, Line): return line_string_mapping([entity.dxf.start, entity.dxf.end]) elif isinstance(entity, Polyline): if entity.is_3d_polyline or entity.is_2d_polyline: # May contain arcs as bulge values: path = make_path(entity) points = list(path.flattening(distance)) return _line_string_or_polygon_mapping(points, force_line_string) else: raise TypeError("Polymesh and Polyface not supported.") elif isinstance(entity, LWPolyline): # May contain arcs as bulge values: path = make_path(entity) points = list(path.flattening(distance)) return _line_string_or_polygon_mapping(points, force_line_string) elif dxftype in {"CIRCLE", "ARC", "ELLIPSE", "SPLINE"}: return _line_string_or_polygon_mapping( list(entity.flattening(distance)), force_line_string # type: ignore ) elif dxftype in {"SOLID", "TRACE", "3DFACE"}: return _line_string_or_polygon_mapping( entity.wcs_vertices(close=True), force_line_string # type: ignore ) elif isinstance(entity, DXFPolygon): return _hatch_as_polygon(entity, distance, force_line_string) else: raise TypeError(dxftype) def _line_string_or_polygon_mapping(points: list[Vec3], force_line_string: bool): len_ = len(points) if len_ < 2: raise ValueError("Invalid vertex count.") if len_ == 2 or force_line_string: return line_string_mapping(points) else: if is_linear_ring(points): return polygon_mapping(points, []) else: return line_string_mapping(points) def _hatch_as_polygon( hatch: DXFPolygon, distance: float, force_line_string: bool ) -> GeoMapping: def boundary_to_vertices(boundary) -> list[Vec3]: path = from_hatch_boundary_path(boundary, ocs, elevation) return path_to_vertices(path) def path_to_vertices(path) -> list[Vec3]: path.close() return list(path.flattening(distance)) # Path vertex winding order can be ignored here, validation and # correction is done in polygon_mapping(). elevation = hatch.dxf.elevation.z ocs = hatch.ocs() hatch_style = hatch.dxf.hatch_style # Returns boundaries in EXTERNAL, OUTERMOST and DEFAULT order and filters # unused boundaries according the hatch style: boundaries = list(hatch.paths.rendering_paths(hatch_style)) count = len(boundaries) if count == 0: raise ValueError(f"{hatch.dxftype()} without any boundary path.") # Take first path as exterior path, multiple EXTERNAL paths are possible exterior = boundaries[0] if count == 1 or hatch_style == const.HATCH_STYLE_IGNORE: points = boundary_to_vertices(exterior) return _line_string_or_polygon_mapping(points, force_line_string) else: if force_line_string: # Build a MultiString collection: points = boundary_to_vertices(exterior) geometries = [_line_string_or_polygon_mapping(points, force_line_string)] # All other boundary paths are treated as holes for hole in boundaries[1:]: points = boundary_to_vertices(hole) geometries.append( _line_string_or_polygon_mapping(points, force_line_string) ) return join_multi_single_type_mappings(geometries) else: # Multiple separated polygons are possible in one HATCH entity: polygons = [] for exterior, holes in _boundaries_to_polygons(boundaries, ocs, elevation): points = path_to_vertices(exterior) polygons.append( polygon_mapping(points, [path_to_vertices(hole) for hole in holes]) ) if len(polygons) > 1: return join_multi_single_type_mappings(polygons) return polygons[0] def _boundaries_to_polygons(boundaries, ocs, elevation): paths = ( from_hatch_boundary_path(boundary, ocs, elevation) for boundary in boundaries ) for polygon in make_polygon_structure(paths): exterior = polygon[0] # only take exterior path of level 1 holes, nested holes are ignored yield exterior, [hole[0] for hole in polygon[1:]] def collection( entities: Iterable[DXFGraphic], distance: float = MAX_FLATTENING_DISTANCE, force_line_string: bool = False, ) -> GeoMapping: """Create the ``__geo_interface__`` mapping as :class:`dict` for the given DXF `entities`, see https://gist.github.com/sgillies/2217756 Returns a "MultiPoint", "MultiLineString" or "MultiPolygon" collection if all entities return the same GeoJSON type ("Point", "LineString", "Polygon") else a "GeometryCollection". Internal API - result is **not** a valid ``_geo_interface__`` mapping! Args: entities: iterable of DXF entities distance: maximum flattening distance for curve approximations force_line_string: by default this function returns "Polygon" objects for closed geometries like CIRCLE, SOLID, closed POLYLINE and so on, by setting argument `force_line_string` to ``True``, this entities will be returned as "LineString" objects. """ m = [mapping(e, distance, force_line_string) for e in entities] types = set(g[TYPE] for g in m) if len(types) > 1: return geometry_collection_mapping(m) else: return join_multi_single_type_mappings(m) def line_string_mapping(points: list[Vec3]) -> GeoMapping: """Returns a "LineString" mapping. .. code:: { "type": "LineString", "coordinates": [ (100.0, 0.0), (101.0, 1.0) ] } """ return {TYPE: LINE_STRING, COORDINATES: points} def is_linear_ring(points: list[Vec3]): return points[0].isclose(points[-1]) # GeoJSON : A linear ring MUST follow the right-hand rule with respect # to the area it bounds, i.e., exterior rings are counterclockwise, and # holes are clockwise. def linear_ring(points: list[Vec3], ccw=True) -> list[Vec3]: """Return `points` as linear ring (last vertex == first vertex), argument `ccw` defines the winding orientation, ``True`` for counter-clock wise and ``False`` for clock wise. """ if len(points) < 3: raise ValueError(f"Invalid vertex count: {len(points)}") if not points[0].isclose(points[-1]): points.append(points[0]) if has_clockwise_orientation(points): if ccw: points.reverse() else: if not ccw: points.reverse() return points def polygon_mapping(points: list[Vec3], holes: list[list[Vec3]]) -> GeoMapping: """Returns a "Polygon" mapping. .. code:: { "type": "Polygon", "coordinates": [ [ (100.0, 0.0), (101.0, 0.0), (101.0, 1.0), (100.0, 1.0), (100.0, 0.0) ], [ (100.8, 0.8), (100.8, 0.2), (100.2, 0.2), (100.2, 0.8), (100.8, 0.8) ] ] } """ exterior = linear_ring(points, ccw=True) if holes: holes = [linear_ring(hole, ccw=False) for hole in holes] rings = exterior, holes else: rings = exterior, [] return { TYPE: POLYGON, COORDINATES: rings, } def join_multi_single_type_mappings(geometries: Iterable[GeoMapping]) -> GeoMapping: """Returns multiple geometries as a "MultiPoint", "MultiLineString" or "MultiPolygon" mapping. """ types = set() data = list() for g in geometries: types.add(g[TYPE]) data.append(g[COORDINATES]) if len(types) > 1: raise TypeError(f"Type mismatch: {str(types)}") elif len(types) == 0: return dict() else: return {TYPE: "Multi" + tuple(types)[0], COORDINATES: data} def geometry_collection_mapping(geometries: Iterable[GeoMapping]) -> GeoMapping: """Returns multiple geometries as a "GeometryCollection" mapping.""" return {TYPE: GEOMETRY_COLLECTION, GEOMETRIES: list(geometries)} # Values stored in GeoData RSS tag are not precise enough to match # control calculation at epsg.io: # Semi Major Axis: 6.37814e+06 # Semi Minor Axis: 6.35675e+06 WGS84_SEMI_MAJOR_AXIS = 6378137 WGS84_SEMI_MINOR_AXIS = 6356752.3142 WGS84_ELLIPSOID_ECCENTRIC = math.sqrt( 1.0 - WGS84_SEMI_MINOR_AXIS**2 / WGS84_SEMI_MAJOR_AXIS**2 ) CONST_E2 = math.e / 2.0 CONST_PI_2 = math.pi / 2.0 CONST_PI_4 = math.pi / 4.0 def wgs84_4326_to_3395(location: Vec3) -> Vec3: """Transform WGS84 `EPSG:4326 `_ location given as latitude and longitude in decimal degrees as used by GPS into World Mercator cartesian 2D coordinates in meters `EPSG:3395 `_. Args: location: :class:`Vec3` object, x-attribute represents the longitude value (East-West) in decimal degrees and the y-attribute represents the latitude value (North-South) in decimal degrees. """ return Vec3(gps_to_world_mercator(location.x, location.y)) def wgs84_3395_to_4326(location: Vec3, tol: float = 1e-6) -> Vec3: """Transform WGS84 World Mercator `EPSG:3395 `_ location given as cartesian 2D coordinates x, y in meters into WGS84 decimal degrees as longitude and latitude `EPSG:4326 `_ as used by GPS. Args: location: :class:`Vec3` object, z-axis is ignored tol: accuracy for latitude calculation """ return Vec3(world_mercator_to_gps(location.x, location.y, tol)) def dms2dd(d: float, m: float = 0, s: float = 0) -> float: """Convert degree, minutes, seconds into decimal degrees.""" dd = d + float(m) / 60 + float(s) / 3600 return dd def dd2dms(dd: float) -> tuple[float, float, float]: """Convert decimal degrees into degree, minutes, seconds.""" m, s = divmod(dd * 3600, 60) d, m = divmod(m, 60) return d, m, s def assign_layers(entity: DXFGraphic, mapping: GeoMapping) -> None: """Reference implementation for a :func:`post_process` function. .. seealso:: :func:`dxf_entities` """ properties = mapping.get(PROPERTIES) if properties is None: return layer = properties.get("layer") if layer: entity.dxf.layer = layer