Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/entities/geodata.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

624 lines
23 KiB
Python

# 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 = """<?xml version="1.0" encoding="UTF-16" standalone="no" ?>
<Dictionary version="1.0" xmlns="http://www.osgeo.org/mapguide/coordinatesystem">
<ProjectedCoordinateSystem id="WORLD-MERCATOR">
<Name>WORLD-MERCATOR</Name>
<AdditionalInformation>
<ParameterItem type="CsMap">
<Key>CSQuadrantSimplified</Key>
<IntegerValue>1</IntegerValue>
</ParameterItem>
</AdditionalInformation>
<DomainOfValidity>
<Extent>
<GeographicElement>
<GeographicBoundingBox>
<WestBoundLongitude>-180.75</WestBoundLongitude>
<EastBoundLongitude>180.75</EastBoundLongitude>
<SouthBoundLatitude>-80.75</SouthBoundLatitude>
<NorthBoundLatitude>84.75</NorthBoundLatitude>
</GeographicBoundingBox>
</GeographicElement>
</Extent>
</DomainOfValidity>
<DatumId>WGS84</DatumId>
<Axis uom="Meter">
<CoordinateSystemAxis>
<AxisOrder>1</AxisOrder>
<AxisName>Easting</AxisName>
<AxisAbbreviation>E</AxisAbbreviation>
<AxisDirection>East</AxisDirection>
</CoordinateSystemAxis>
<CoordinateSystemAxis>
<AxisOrder>2</AxisOrder>
<AxisName>Northing</AxisName>
<AxisAbbreviation>N</AxisAbbreviation>
<AxisDirection>North</AxisDirection>
</CoordinateSystemAxis>
</Axis>
<Conversion>
<Projection>
<OperationMethodId>Mercator (variant B)</OperationMethodId>
<ParameterValue><OperationParameterId>Longitude of natural origin</OperationParameterId><Value uom="degree">0</Value></ParameterValue>
<ParameterValue><OperationParameterId>Standard Parallel</OperationParameterId><Value uom="degree">0</Value></ParameterValue>
<ParameterValue><OperationParameterId>Scaling factor for coord differences</OperationParameterId><Value uom="unity">1</Value></ParameterValue>
<ParameterValue><OperationParameterId>False easting</OperationParameterId><Value uom="Meter">0</Value></ParameterValue>
<ParameterValue><OperationParameterId>False northing</OperationParameterId><Value uom="Meter">0</Value></ParameterValue>
</Projection>
</Conversion>
</ProjectedCoordinateSystem>
<Alias id="3395" type="CoordinateSystem">
<ObjectId>WORLD-MERCATOR</ObjectId>
<Namespace>EPSG Code</Namespace>
</Alias>
<GeodeticDatum id="WGS84">
<Name>WGS84</Name>
<PrimeMeridianId>Greenwich</PrimeMeridianId>
<EllipsoidId>WGS84</EllipsoidId>
</GeodeticDatum>
<Alias id="6326" type="Datum">
<ObjectId>WGS84</ObjectId>
<Namespace>EPSG Code</Namespace>
</Alias>
<Ellipsoid id="WGS84">
<Name>WGS84</Name>
<SemiMajorAxis uom="meter">6.37814e+06</SemiMajorAxis>
<SecondDefiningParameter>
<SemiMinorAxis uom="meter">6.35675e+06</SemiMinorAxis>
</SecondDefiningParameter>
</Ellipsoid>
<Alias id="7030" type="Ellipsoid">
<ObjectId>WGS84</ObjectId>
<Namespace>EPSG Code</Namespace>
</Alias>
</Dictionary>
"""
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::
<Alias id="27700" type="CoordinateSystem">
<ObjectId>OSGB1936.NationalGrid</ObjectId>
<Namespace>EPSG Code</Namespace>
</Alias>
The axis-ordering is stored in a tag like:
.. code::
<Axis uom="METER">
<CoordinateSystemAxis>
<AxisOrder>1</AxisOrder>
<AxisName>Easting</AxisName>
<AxisAbbreviation>E</AxisAbbreviation>
<AxisDirection>east</AxisDirection>
</CoordinateSystemAxis>
<CoordinateSystemAxis>
<AxisOrder>2</AxisOrder>
<AxisName>Northing</AxisName>
<AxisAbbreviation>N</AxisAbbreviation>
<AxisDirection>north</AxisDirection>
</CoordinateSystemAxis>
</Axis>
"""
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)