# Copyright (c) 2019-2023, Manfred Moitzi # License: MIT-License from __future__ import annotations from typing import TYPE_CHECKING, Sequence, Iterable, Optional from xml.etree import ElementTree import math import re import logging from ezdxf.lldxf import validator from ezdxf.lldxf.attributes import ( DXFAttributes, DefSubclass, DXFAttr, XType, RETURN_DEFAULT, group_code_mapping, ) from ezdxf.lldxf.const import ( SUBCLASS_MARKER, DXFStructureError, DXF2010, DXFTypeError, DXFValueError, InvalidGeoDataException, ) from ezdxf.lldxf.packedtags import VertexArray from ezdxf.lldxf.tags import Tags, DXFTag from ezdxf.math import NULLVEC, Z_AXIS, Y_AXIS, UVec, Vec3, Vec2, Matrix44 from ezdxf.tools.text import split_mtext_string from .dxfentity import base_class, SubclassProcessor from .dxfobj import DXFObject from .factory import register_entity from .copy import default_copy, CopyNotSupported from .. import units if TYPE_CHECKING: from ezdxf.entities import DXFNamespace from ezdxf.lldxf.tagwriter import AbstractTagWriter __all__ = ["GeoData", "MeshVertices"] logger = logging.getLogger("ezdxf") acdb_geo_data = DefSubclass( "AcDbGeoData", { # 1 = R2009, but this release has no DXF version, # 2 = R2010 "version": DXFAttr(90, default=2), # Handle to host block table record "block_record_handle": DXFAttr(330, default="0"), # 0 = unknown # 1 = local grid # 2 = projected grid # 3 = geographic (latitude/longitude) "coordinate_type": DXFAttr( 70, default=3, validator=validator.is_in_integer_range(0, 4), fixer=RETURN_DEFAULT, ), # Design point, reference point in WCS coordinates: "design_point": DXFAttr(10, xtype=XType.point3d, default=NULLVEC), # Reference point in coordinate system coordinates, valid only when # coordinate type is Local Grid: "reference_point": DXFAttr(11, xtype=XType.point3d, default=NULLVEC), # Horizontal unit scale, factor which converts horizontal design coordinates # to meters by multiplication: "horizontal_unit_scale": DXFAttr( 40, default=1, validator=validator.is_not_zero, fixer=RETURN_DEFAULT, ), # Horizontal units per UnitsValue enumeration. Will be kUnitsUndefined if # units specified by horizontal unit scale is not supported by AutoCAD # enumeration: "horizontal_units": DXFAttr(91, default=1), # Vertical unit scale, factor which converts vertical design coordinates # to meters by multiplication: "vertical_unit_scale": DXFAttr(41, default=1), # Vertical units per UnitsValue enumeration. Will be kUnitsUndefined if # units specified by vertical unit scale is not supported by AutoCAD # enumeration: "vertical_units": DXFAttr(92, default=1), "up_direction": DXFAttr( 210, xtype=XType.point3d, default=Z_AXIS, validator=validator.is_not_null_vector, fixer=RETURN_DEFAULT, ), # North direction vector (2D) # In DXF R2009 the group code 12 is used for source mesh points! "north_direction": DXFAttr( 12, xtype=XType.point2d, default=Y_AXIS, validator=validator.is_not_null_vector, fixer=RETURN_DEFAULT, ), # Scale estimation methods: # 1 = None # 2 = User specified scale factor; # 3 = Grid scale at reference point; # 4 = Prismoidal "scale_estimation_method": DXFAttr( 95, default=1, validator=validator.is_in_integer_range(0, 5), fixer=RETURN_DEFAULT, ), "user_scale_factor": DXFAttr( 141, default=1, validator=validator.is_not_zero, fixer=RETURN_DEFAULT, ), # Bool flag specifying whether to do sea level correction: "sea_level_correction": DXFAttr( 294, default=0, validator=validator.is_integer_bool, fixer=RETURN_DEFAULT, ), "sea_level_elevation": DXFAttr(142, default=0), "coordinate_projection_radius": DXFAttr(143, default=0), # 303, 303, ..., 301: Coordinate system definition string "geo_rss_tag": DXFAttr(302, default="", optional=True), "observation_from_tag": DXFAttr(305, default="", optional=True), "observation_to_tag": DXFAttr(306, default="", optional=True), "observation_coverage_tag": DXFAttr(307, default="", optional=True), # 93: Number of Geo-Mesh points # mesh definition: # source mesh point (12, 22) repeat, mesh_point_count? for version == 1 # target mesh point (13, 14) repeat, mesh_point_count? for version == 1 # source mesh point (13, 23) repeat, mesh_point_count? for version > 1 # target mesh point (14, 24) repeat, mesh_point_count? for version > 1 # 96: # Number of faces # face index 97 repeat, faces_count # face index 98 repeat, faces_count # face index 99 repeat, faces_count }, ) acdb_geo_data_group_codes = group_code_mapping(acdb_geo_data) EPSG_3395 = """ WORLD-MERCATOR CSQuadrantSimplified 1 -180.75 180.75 -80.75 84.75 WGS84 1 Easting E East 2 Northing N North Mercator (variant B) Longitude of natural origin0 Standard Parallel0 Scaling factor for coord differences1 False easting0 False northing0 WORLD-MERCATOR EPSG Code WGS84 Greenwich WGS84 WGS84 EPSG Code WGS84 6.37814e+06 6.35675e+06 WGS84 EPSG Code """ class MeshVertices(VertexArray): VERTEX_SIZE = 2 def mesh_group_codes(version: int) -> tuple[int, int]: return (12, 13) if version < 2 else (13, 14) @register_entity class GeoData(DXFObject): """DXF GEODATA entity""" DXFTYPE = "GEODATA" DXFATTRIBS = DXFAttributes(base_class, acdb_geo_data) MIN_DXF_VERSION_FOR_EXPORT = DXF2010 # coordinate_type const UNKNOWN = 0 LOCAL_GRID = 1 PROJECTED_GRID = 2 GEOGRAPHIC = 3 # scale_estimation_method const NONE = 1 USER_SCALE = 2 GRID_SCALE = 3 PRISMOIDEAL = 4 def __init__(self) -> None: super().__init__() self.source_vertices = MeshVertices() self.target_vertices = MeshVertices() self.faces: list[Sequence[int]] = [] self.coordinate_system_definition = "" def copy(self, copy_strategy=default_copy): raise CopyNotSupported(f"Copying of {self.DXFTYPE} not supported.") def load_dxf_attribs( self, processor: Optional[SubclassProcessor] = None ) -> DXFNamespace: dxf = super().load_dxf_attribs(processor) if processor: version = processor.detect_implementation_version( subclass_index=1, group_code=90, default=2, ) tags = processor.fast_load_dxfattribs( dxf, acdb_geo_data_group_codes, 1, log=False ) tags = self.load_coordinate_system_definition(tags) # type: ignore if version > 1: self.load_mesh_data(tags, version) else: # version 1 is not really supported, because the group codes are # totally messed up! logger.warning( "GEODATA version 1 found, perhaps loaded data is incorrect" ) dxf.discard("north_direction") # group code issue!!! return dxf def load_coordinate_system_definition(self, tags: Tags) -> Iterable[DXFTag]: # 303, 303, 301, Coordinate system definition string, always XML? lines = [] for tag in tags: if tag.code in (301, 303): lines.append(tag.value.replace("^J", "\n")) else: yield tag if len(lines): self.coordinate_system_definition = "".join(lines) def load_mesh_data(self, tags: Iterable[DXFTag], version: int = 2): # group codes of version 1 and 2 differ, see DXF reference R2009 src, target = mesh_group_codes(version) face_indices = {97, 98, 99} face: list[int] = [] for code, value in tags: if code == src: self.source_vertices.append(value) elif code == target: self.target_vertices.append(value) elif code in face_indices: if code == 97 and len(face): if len(face) != 3: raise DXFStructureError( f"GEODATA face definition error: invalid index " f"count {len(face)}." ) self.faces.append(tuple(face)) face.clear() face.append(value) if face: # collect last face self.faces.append(tuple(face)) if len(self.source_vertices) != len(self.target_vertices): raise DXFStructureError( "GEODATA mesh definition error: source and target point count " "does not match." ) def export_entity(self, tagwriter: AbstractTagWriter) -> None: """Export entity specific data as DXF tags.""" super().export_entity(tagwriter) tagwriter.write_tag2(SUBCLASS_MARKER, acdb_geo_data.name) if self.dxf.version < 2: logger.warning( "exporting unsupported GEODATA version 1, this may corrupt " "the DXF file!" ) self.dxf.export_dxf_attribs( tagwriter, [ "version", "block_record_handle", "coordinate_type", "design_point", "reference_point", "horizontal_unit_scale", "horizontal_units", "vertical_unit_scale", "vertical_units", "up_direction", "north_direction", "scale_estimation_method", "user_scale_factor", "sea_level_correction", "sea_level_elevation", "coordinate_projection_radius", ], ) self.export_coordinate_system_definition(tagwriter) self.dxf.export_dxf_attribs( tagwriter, [ "geo_rss_tag", "observation_from_tag", "observation_to_tag", "observation_coverage_tag", ], ) self.export_mesh_data(tagwriter) def export_mesh_data(self, tagwriter: AbstractTagWriter): if len(self.source_vertices) != len(self.target_vertices): raise DXFStructureError( "GEODATA mesh definition error: source and target point count " "does not match." ) src, target = mesh_group_codes(self.dxf.version) tagwriter.write_tag2(93, len(self.source_vertices)) for s, t in zip(self.source_vertices, self.target_vertices): tagwriter.write_vertex(src, s) tagwriter.write_vertex(target, t) tagwriter.write_tag2(96, len(self.faces)) for face in self.faces: if len(face) != 3: raise DXFStructureError( f"GEODATA face definition error: invalid index " f"count {len(face)}." ) f1, f2, f3 = face tagwriter.write_tag2(97, f1) tagwriter.write_tag2(98, f2) tagwriter.write_tag2(99, f3) def export_coordinate_system_definition(self, tagwriter: AbstractTagWriter): text = self.coordinate_system_definition.replace("\n", "^J") chunks = split_mtext_string(text, size=255) if len(chunks) == 0: chunks.append("") while len(chunks) > 1: tagwriter.write_tag2(303, chunks.pop(0)) tagwriter.write_tag2(301, chunks[0]) def decoded_units(self) -> tuple[Optional[str], Optional[str]]: return units.decode(self.dxf.horizontal_units), units.decode( self.dxf.vertical_units ) def get_crs(self) -> tuple[int, bool]: """Returns the EPSG index and axis-ordering, axis-ordering is ``True`` if fist axis is labeled "E" or "W" and ``False`` if first axis is labeled "N" or "S". If axis-ordering is ``False`` the CRS is not compatible with the ``__geo_interface__`` or GeoJSON (see chapter 3.1.1). Raises: InvalidGeoDataException: for invalid or unknown XML data The EPSG number is stored in a tag like: .. code:: OSGB1936.NationalGrid EPSG Code The axis-ordering is stored in a tag like: .. code:: 1 Easting E east 2 Northing N north """ definition = self.coordinate_system_definition try: # Remove namespaces so that tags can be searched without prefixing # their namespace: definition = _remove_xml_namespaces(definition) root = ElementTree.fromstring(definition) except ElementTree.ParseError: raise InvalidGeoDataException( "failed to parse coordinate_system_definition as xml" ) crs = None for alias in root.findall("Alias"): try: namespace = alias.find("Namespace").text # type: ignore except AttributeError: namespace = "" if alias.get("type") == "CoordinateSystem" and namespace == "EPSG Code": try: crs = int(alias.get("id")) # type: ignore except ValueError: raise InvalidGeoDataException( f'invalid epsg number: {alias.get("id")}' ) break xy_ordering = None for axis in root.findall(".//CoordinateSystemAxis"): try: axis_order = axis.find("AxisOrder").text # type: ignore except AttributeError: axis_order = "" if axis_order == "1": try: first_axis = axis.find("AxisAbbreviation").text # type: ignore except AttributeError: raise InvalidGeoDataException("first axis not defined") if first_axis in ("E", "W"): xy_ordering = True elif first_axis in ("N", "S"): xy_ordering = False else: raise InvalidGeoDataException(f"unknown first axis: {first_axis}") break if crs is None: raise InvalidGeoDataException("no EPSG code associated with CRS") elif xy_ordering is None: raise InvalidGeoDataException("could not determine axis ordering") else: return crs, xy_ordering def get_crs_transformation( self, *, no_checks: bool = False ) -> tuple[Matrix44, int]: """Returns the transformation matrix and the EPSG index to transform WCS coordinates into CRS coordinates. Because of the lack of proper documentation this method works only for tested configurations, set argument `no_checks` to ``True`` to use the method for untested geodata configurations, but the results may be incorrect. Supports only "Local Grid" transformation! Raises: InvalidGeoDataException: for untested geodata configurations """ epsg, xy_ordering = self.get_crs() if not no_checks: if ( self.dxf.coordinate_type != GeoData.LOCAL_GRID or self.dxf.scale_estimation_method != GeoData.NONE or not math.isclose(self.dxf.user_scale_factor, 1.0) or self.dxf.sea_level_correction != 0 or not math.isclose(self.dxf.sea_level_elevation, 0) or self.faces or not self.dxf.up_direction.isclose((0, 0, 1)) or self.dxf.observation_coverage_tag != "" or self.dxf.observation_from_tag != "" or self.dxf.observation_to_tag != "" or not xy_ordering ): raise InvalidGeoDataException( f"Untested geodata configuration: " f"{self.dxf.all_existing_dxf_attribs()}.\n" f"You can try with no_checks=True but the " f"results may be incorrect." ) source = self.dxf.design_point # in CAD WCS coordinates target = self.dxf.reference_point # in the CRS of the geodata north = self.dxf.north_direction # -pi/2 because north is at pi/2 so if the given north is at pi/2, no # rotation is necessary: theta = -(math.atan2(north.y, north.x) - math.pi / 2) transformation = ( Matrix44.translate(-source.x, -source.y, 0) @ Matrix44.scale( self.dxf.horizontal_unit_scale, self.dxf.vertical_unit_scale, 1 ) @ Matrix44.z_rotate(theta) @ Matrix44.translate(target.x, target.y, 0) ) return transformation, epsg def setup_local_grid( self, *, design_point: UVec, reference_point: UVec, north_direction: UVec = (0, 1), crs: str = EPSG_3395, ) -> None: """Setup local grid coordinate system. This method is designed to setup CRS similar to `EPSG:3395 World Mercator`, the basic features of the CRS should fulfill these assumptions: - base unit of reference coordinates is 1 meter - right-handed coordinate system: +Y=north/+X=east/+Z=up The CRS string is not validated nor interpreted! .. hint:: The reference point must be a 2D cartesian map coordinate and not a globe (lon/lat) coordinate like stored in GeoJSON or GPS data. Args: design_point: WCS coordinates of the CRS reference point reference_point: CRS reference point in 2D cartesian coordinates north_direction: north direction a 2D vertex, default is (0, 1) crs: Coordinate Reference System definition XML string, default is the definition string for `EPSG:3395 World Mercator` """ doc = self.doc if doc is None: raise DXFValueError("Valid DXF document required.") wcs_units = doc.units if units == 0: raise DXFValueError( "DXF document requires units to be set, " 'current state is "unitless".' ) meter_factor = units.METER_FACTOR[wcs_units] if meter_factor is None: raise DXFValueError(f"Unsupported document units: {wcs_units}") unit_factor = 1.0 / meter_factor # Default settings: self.dxf.up_direction = Z_AXIS self.dxf.observation_coverage_tag = "" self.dxf.observation_from_tag = "" self.dxf.observation_to_tag = "" self.dxf.scale_estimation_method = GeoData.NONE self.dxf.coordinate_type = GeoData.LOCAL_GRID self.dxf.sea_level_correction = 0 self.dxf.horizontal_units = wcs_units # Factor from WCS -> CRS (m) e.g. 0.01 for horizontal_units==5 (cm), # 1cm = 0.01m self.dxf.horizontal_unit_scale = unit_factor self.dxf.vertical_units = wcs_units self.dxf.vertical_unit_scale = unit_factor # User settings: self.dxf.design_point = Vec3(design_point) self.dxf.reference_point = Vec3(reference_point) self.dxf.north_direction = Vec2(north_direction) self.coordinate_system_definition = str(crs) def _remove_xml_namespaces(xml_string: str) -> str: return re.sub('xmlns="[^"]*"', "", xml_string)