# Copyright (c) 2019-2024 Manfred Moitzi # License: MIT License from __future__ import annotations from typing import TYPE_CHECKING, Optional, Iterable, Any from typing_extensions import Self, TypeGuard from ezdxf.entities import factory from ezdxf import options from ezdxf.lldxf import validator from ezdxf.lldxf.attributes import ( DXFAttr, DXFAttributes, DefSubclass, RETURN_DEFAULT, group_code_mapping, ) from ezdxf import colors as clr from ezdxf.lldxf import const from ezdxf.lldxf.const import ( DXF12, DXF2000, DXF2004, DXF2007, DXF2013, SUBCLASS_MARKER, TRANSPARENCY_BYBLOCK, ) from ezdxf.math import OCS, Matrix44, UVec from ezdxf.proxygraphic import load_proxy_graphic, export_proxy_graphic from .dxfentity import DXFEntity, base_class, SubclassProcessor, DXFTagStorage if TYPE_CHECKING: from ezdxf.audit import Auditor from ezdxf.document import Drawing from ezdxf.entities import DXFNamespace from ezdxf.layouts import BaseLayout from ezdxf.lldxf.tagwriter import AbstractTagWriter from ezdxf import xref __all__ = [ "DXFGraphic", "acdb_entity", "acdb_entity_group_codes", "SeqEnd", "add_entity", "replace_entity", "elevation_to_z_axis", "is_graphic_entity", "get_font_name", ] GRAPHIC_PROPERTIES = { "layer", "linetype", "color", "lineweight", "ltscale", "true_color", "color_name", "transparency", } acdb_entity: DefSubclass = DefSubclass( "AcDbEntity", { # Layer name as string, no auto fix for invalid names! "layer": DXFAttr(8, default="0", validator=validator.is_valid_layer_name), # Linetype name as string, no auto fix for invalid names! "linetype": DXFAttr( 6, default="BYLAYER", optional=True, validator=validator.is_valid_table_name, ), # ACI color index, BYBLOCK=0, BYLAYER=256, BYOBJECT=257: "color": DXFAttr( 62, default=256, optional=True, validator=validator.is_valid_aci_color, fixer=RETURN_DEFAULT, ), # modelspace=0, paperspace=1 "paperspace": DXFAttr( 67, default=0, optional=True, validator=validator.is_integer_bool, fixer=RETURN_DEFAULT, ), # Lineweight in mm times 100 (e.g. 0.13mm = 13). Smallest line weight is 13 # and biggest line weight is 200, values outside this range prevents AutoCAD # from loading the file. # Special values: BYLAYER=-1, BYBLOCK=-2, DEFAULT=-3 "lineweight": DXFAttr( 370, default=-1, dxfversion=DXF2000, optional=True, validator=validator.is_valid_lineweight, fixer=validator.fix_lineweight, ), "ltscale": DXFAttr( 48, default=1.0, dxfversion=DXF2000, optional=True, validator=validator.is_positive, fixer=RETURN_DEFAULT, ), # visible=0, invisible=1 "invisible": DXFAttr(60, default=0, dxfversion=DXF2000, optional=True), # True color as 0x00RRGGBB 24-bit value # True color always overrides ACI "color"! "true_color": DXFAttr(420, dxfversion=DXF2004, optional=True), # Color name as string. Color books are stored in .stb config files? "color_name": DXFAttr(430, dxfversion=DXF2004, optional=True), # Transparency value 0x020000TT 0 = fully transparent / 255 = opaque # Special value 0x01000000 == ByBlock # unset value means ByLayer "transparency": DXFAttr( 440, dxfversion=DXF2004, optional=True, validator=validator.is_transparency, ), # Shadow mode: # 0 = Casts and receives shadows # 1 = Casts shadows # 2 = Receives shadows # 3 = Ignores shadows "shadow_mode": DXFAttr(284, dxfversion=DXF2007, optional=True), "material_handle": DXFAttr(347, dxfversion=DXF2007, optional=True), "visualstyle_handle": DXFAttr(348, dxfversion=DXF2007, optional=True), # PlotStyleName type enum (AcDb::PlotStyleNameType). Stored and moved around # as a 16-bit integer. Custom non-entity "plotstyle_enum": DXFAttr(380, dxfversion=DXF2007, default=1, optional=True), # Handle value of the PlotStyleName object, basically a hard pointer, but # has a different range to make backward compatibility easier to deal with. "plotstyle_handle": DXFAttr(390, dxfversion=DXF2007, optional=True), # 92 or 160?: Number of bytes in the proxy entity graphics represented in # the subsequent 310 groups, which are binary chunk records (optional) # 310: Proxy entity graphics data (multiple lines; 256 characters max. per # line) (optional), compiled by TagCompiler() to a DXFBinaryTag() objects }, ) acdb_entity_group_codes = group_code_mapping(acdb_entity) def elevation_to_z_axis(dxf: DXFNamespace, names: Iterable[str]): # The elevation group code (38) is only used for DXF R11 and prior and # ignored for DXF R2000 and later. # DXF R12 and later store the entity elevation in the z-axis of the # vertices, but AutoCAD supports elevation for R12 if no z-axis is present. # DXF types with legacy elevation support: # SOLID, TRACE, TEXT, CIRCLE, ARC, TEXT, ATTRIB, ATTDEF, INSERT, SHAPE # The elevation is only used for DXF R12 if no z-axis is stored in the DXF # file. This is a problem because ezdxf loads the vertices always as 3D # vertex including a z-axis even if no z-axis is present in DXF file. if dxf.hasattr("elevation"): elevation = dxf.elevation # ezdxf does not export the elevation attribute for any DXF version dxf.discard("elevation") if elevation == 0: return for name in names: v = dxf.get(name) # Only use elevation value if z-axis is 0, this will not work for # situations where an elevation and a z-axis=0 is present, but let's # assume if the elevation group code is used the z-axis is not # present if z-axis is 0. if v is not None and v.z == 0: dxf.set(name, v.replace(z=elevation)) class DXFGraphic(DXFEntity): """Common base class for all graphic entities, a subclass of :class:`~ezdxf.entities.dxfentity.DXFEntity`. These entities resides in entity spaces like modelspace, paperspace or block. """ DXFTYPE = "DXFGFX" DEFAULT_ATTRIBS: dict[str, Any] = {"layer": "0"} DXFATTRIBS = DXFAttributes(base_class, acdb_entity) def load_dxf_attribs( self, processor: Optional[SubclassProcessor] = None ) -> DXFNamespace: """Adds subclass processing for 'AcDbEntity', requires previous base class processing by parent class. (internal API) """ # subclasses using simple_dxfattribs_loader() bypass this method!!! dxf = super().load_dxf_attribs(processor) if processor is None: return dxf r12 = processor.r12 # It is valid to mix up the base class with AcDbEntity class. processor.append_base_class_to_acdb_entity() # Load proxy graphic data if requested if options.load_proxy_graphics: # length tag has group code 92 until DXF R2010 if processor.dxfversion and processor.dxfversion < DXF2013: code = 92 else: code = 160 self.proxy_graphic = load_proxy_graphic( processor.subclasses[0 if r12 else 1], length_code=code, ) processor.fast_load_dxfattribs(dxf, acdb_entity_group_codes, 1) return dxf def post_new_hook(self) -> None: """Post-processing and integrity validation after entity creation. (internal API) """ if self.doc: if self.dxf.linetype not in self.doc.linetypes: raise const.DXFInvalidLineType( f'Linetype "{self.dxf.linetype}" not defined.' ) @property def rgb(self) -> tuple[int, int, int] | None: """Returns RGB true color as (r, g, b) tuple or None if true_color is not set.""" if self.dxf.hasattr("true_color"): return clr.int2rgb(self.dxf.get("true_color")) return None @rgb.setter def rgb(self, rgb: clr.RGB | tuple[int, int, int]) -> None: """Set RGB true color as (r, g , b) tuple e.g. (12, 34, 56). Raises: TypeError: input value `rgb` has invalid type """ self.dxf.set("true_color", clr.rgb2int(rgb)) @rgb.deleter def rgb(self) -> None: """Delete RGB true color value.""" self.dxf.discard("true_color") @property def transparency(self) -> float: """Get transparency as float value between 0 and 1, 0 is opaque and 1 is 100% transparent (invisible). Transparency by block returns always 0. """ if self.dxf.hasattr("transparency"): value = self.dxf.get("transparency") if validator.is_transparency(value): if value & TRANSPARENCY_BYBLOCK: # just check flag state return 0.0 return clr.transparency2float(value) return 0.0 @transparency.setter def transparency(self, transparency: float) -> None: """Set transparency as float value between 0 and 1, 0 is opaque and 1 is 100% transparent (invisible). """ self.dxf.set("transparency", clr.float2transparency(transparency)) @property def is_transparency_by_layer(self) -> bool: """Returns ``True`` if entity inherits transparency from layer.""" return not self.dxf.hasattr("transparency") @property def is_transparency_by_block(self) -> bool: """Returns ``True`` if entity inherits transparency from block.""" return self.dxf.get("transparency", 0) == TRANSPARENCY_BYBLOCK def graphic_properties(self) -> dict: """Returns the important common properties layer, color, linetype, lineweight, ltscale, true_color and color_name as `dxfattribs` dict. """ attribs = dict() for key in GRAPHIC_PROPERTIES: if self.dxf.hasattr(key): attribs[key] = self.dxf.get(key) return attribs def ocs(self) -> OCS: """Returns object coordinate system (:ref:`ocs`) for 2D entities like :class:`Text` or :class:`Circle`, returns a pass-through OCS for entities without OCS support. """ # extrusion is only defined for 2D entities like Text, Circle, ... if self.dxf.is_supported("extrusion"): extrusion = self.dxf.get("extrusion", default=(0, 0, 1)) return OCS(extrusion) else: return OCS() def set_owner(self, owner: Optional[str], paperspace: int = 0) -> None: """Set owner attribute and paperspace flag. (internal API)""" self.dxf.owner = owner if paperspace: self.dxf.paperspace = paperspace else: self.dxf.discard("paperspace") def link_entity(self, entity: DXFEntity) -> None: """Store linked or attached entities. Same API for both types of appended data, because entities with linked entities (POLYLINE, INSERT) have no attached entities and vice versa. (internal API) """ pass def export_entity(self, tagwriter: AbstractTagWriter) -> None: """Export entity specific data as DXF tags. (internal API)""" # Base class export is done by parent class. self.export_acdb_entity(tagwriter) # XDATA and embedded objects export is also done by the parent class. def export_acdb_entity(self, tagwriter: AbstractTagWriter) -> None: """Export subclass 'AcDbEntity' as DXF tags. (internal API)""" # Full control over tag order and YES, sometimes order matters not_r12 = tagwriter.dxfversion > DXF12 if not_r12: tagwriter.write_tag2(SUBCLASS_MARKER, acdb_entity.name) self.dxf.export_dxf_attribs( tagwriter, [ "paperspace", "layer", "linetype", "material_handle", "color", "lineweight", "ltscale", "invisible", "true_color", "color_name", "transparency", "plotstyle_enum", "plotstyle_handle", "shadow_mode", "visualstyle_handle", ], ) if self.proxy_graphic and not_r12 and options.store_proxy_graphics: # length tag has group code 92 until DXF R2010 export_proxy_graphic( self.proxy_graphic, tagwriter=tagwriter, length_code=(92 if tagwriter.dxfversion < DXF2013 else 160), ) def get_layout(self) -> Optional[BaseLayout]: """Returns the owner layout or returns ``None`` if entity is not assigned to any layout. """ if self.dxf.owner is None or self.doc is None: # unlinked entity return None try: return self.doc.layouts.get_layout_by_key(self.dxf.owner) except const.DXFKeyError: pass try: return self.doc.blocks.get_block_layout_by_handle(self.dxf.owner) except const.DXFTableEntryError: return None def unlink_from_layout(self) -> None: """ Unlink entity from associated layout. Does nothing if entity is already unlinked. It is more efficient to call the :meth:`~ezdxf.layouts.BaseLayout.unlink_entity` method of the associated layout, especially if you have to unlink more than one entity. """ if not self.is_alive: raise TypeError("Can not unlink destroyed entity.") if self.doc is None: # no doc -> no layout self.dxf.owner = None return layout = self.get_layout() if layout: layout.unlink_entity(self) def move_to_layout( self, layout: BaseLayout, source: Optional[BaseLayout] = None ) -> None: """ Move entity from model space or a paper space layout to another layout. For block layout as source, the block layout has to be specified. Moving between different DXF drawings is not supported. Args: layout: any layout (model space, paper space, block) source: provide source layout, faster for DXF R12, if entity is in a block layout Raises: DXFStructureError: for moving between different DXF drawings """ if source is None: source = self.get_layout() if source is None: raise const.DXFValueError("Source layout for entity not found.") source.move_to_layout(self, layout) def copy_to_layout(self, layout: BaseLayout) -> Self: """ Copy entity to another `layout`, returns new created entity as :class:`DXFEntity` object. Copying between different DXF drawings is not supported. Args: layout: any layout (model space, paper space, block) Raises: DXFStructureError: for copying between different DXF drawings """ if self.doc != layout.doc: raise const.DXFStructureError( "Copying between different DXF drawings is not supported." ) new_entity = self.copy() layout.add_entity(new_entity) return new_entity def audit(self, auditor: Auditor) -> None: """Audit and repair graphical DXF entities. .. important:: Do not delete entities while auditing process, because this would alter the entity database while iterating, instead use:: auditor.trash(entity) to delete invalid entities after auditing automatically. """ assert self.doc is auditor.doc, "Auditor for different DXF document." if not self.is_alive: return super().audit(auditor) auditor.check_owner_exist(self) dxf = self.dxf if dxf.hasattr("layer"): auditor.check_for_valid_layer_name(self) if dxf.hasattr("linetype"): auditor.check_entity_linetype(self) if dxf.hasattr("color"): auditor.check_entity_color_index(self) if dxf.hasattr("lineweight"): auditor.check_entity_lineweight(self) if dxf.hasattr("extrusion"): auditor.check_extrusion_vector(self) if dxf.hasattr("transparency"): auditor.check_transparency(self) def transform(self, m: Matrix44) -> Self: """Inplace transformation interface, returns `self` (floating interface). Args: m: 4x4 transformation matrix (:class:`ezdxf.math.Matrix44`) """ raise NotImplementedError() def post_transform(self, m: Matrix44) -> None: """Should be called if the main entity transformation was successful.""" if self.xdata is not None: self.xdata.transform(m) @property def is_post_transform_required(self) -> bool: """Check if post transform call is required.""" return self.xdata is not None def translate(self, dx: float, dy: float, dz: float) -> Self: """Translate entity inplace about `dx` in x-axis, `dy` in y-axis and `dz` in z-axis, returns `self` (floating interface). Basic implementation uses the :meth:`transform` interface, subclasses may have faster implementations. """ return self.transform(Matrix44.translate(dx, dy, dz)) def scale(self, sx: float, sy: float, sz: float) -> Self: """Scale entity inplace about `dx` in x-axis, `dy` in y-axis and `dz` in z-axis, returns `self` (floating interface). """ return self.transform(Matrix44.scale(sx, sy, sz)) def scale_uniform(self, s: float) -> Self: """Scale entity inplace uniform about `s` in x-axis, y-axis and z-axis, returns `self` (floating interface). """ return self.transform(Matrix44.scale(s)) def rotate_axis(self, axis: UVec, angle: float) -> Self: """Rotate entity inplace about vector `axis`, returns `self` (floating interface). Args: axis: rotation axis as tuple or :class:`Vec3` angle: rotation angle in radians """ return self.transform(Matrix44.axis_rotate(axis, angle)) def rotate_x(self, angle: float) -> Self: """Rotate entity inplace about x-axis, returns `self` (floating interface). Args: angle: rotation angle in radians """ return self.transform(Matrix44.x_rotate(angle)) def rotate_y(self, angle: float) -> Self: """Rotate entity inplace about y-axis, returns `self` (floating interface). Args: angle: rotation angle in radians """ return self.transform(Matrix44.y_rotate(angle)) def rotate_z(self, angle: float) -> Self: """Rotate entity inplace about z-axis, returns `self` (floating interface). Args: angle: rotation angle in radians """ return self.transform(Matrix44.z_rotate(angle)) def has_hyperlink(self) -> bool: """Returns ``True`` if entity has an attached hyperlink.""" return bool(self.xdata) and ("PE_URL" in self.xdata) # type: ignore def set_hyperlink( self, link: str, description: Optional[str] = None, location: Optional[str] = None, ): """Set hyperlink of an entity.""" xdata = [(1001, "PE_URL"), (1000, str(link))] if description: xdata.append((1002, "{")) xdata.append((1000, str(description))) if location: xdata.append((1000, str(location))) xdata.append((1002, "}")) self.discard_xdata("PE_URL") self.set_xdata("PE_URL", xdata) if self.doc and "PE_URL" not in self.doc.appids: self.doc.appids.new("PE_URL") return self def get_hyperlink(self) -> tuple[str, str, str]: """Returns hyperlink, description and location.""" link = "" description = "" location = "" if self.xdata and "PE_URL" in self.xdata: xdata = [tag.value for tag in self.get_xdata("PE_URL") if tag.code == 1000] if len(xdata): link = xdata[0] if len(xdata) > 1: description = xdata[1] if len(xdata) > 2: location = xdata[2] return link, description, location def remove_dependencies(self, other: Optional[Drawing] = None) -> None: """Remove all dependencies from current document. (internal API) """ if not self.is_alive: return super().remove_dependencies(other) # The layer attribute is preserved because layer doesn't need a layer # table entry, the layer attributes are reset to default attributes # like color is 7 and linetype is CONTINUOUS has_linetype = other is not None and (self.dxf.linetype in other.linetypes) if not has_linetype: self.dxf.linetype = "BYLAYER" self.dxf.discard("material_handle") self.dxf.discard("visualstyle_handle") self.dxf.discard("plotstyle_enum") self.dxf.discard("plotstyle_handle") def _new_compound_entity(self, type_: str, dxfattribs) -> Self: """Create and bind new entity with same layout settings as `self`. Used by INSERT & POLYLINE to create appended DXF entities, don't use it to create new standalone entities. (internal API) """ dxfattribs = dxfattribs or {} # if layer is not deliberately set, set same layer as creator entity, # at least VERTEX should have the same layer as the POLYGON entity. # Don't know if that is also important for the ATTRIB & INSERT entity. if "layer" not in dxfattribs: dxfattribs["layer"] = self.dxf.layer if self.doc: entity = factory.create_db_entry(type_, dxfattribs, self.doc) else: entity = factory.new(type_, dxfattribs) entity.dxf.owner = self.dxf.owner entity.dxf.paperspace = self.dxf.paperspace return entity # type: ignore def register_resources(self, registry: xref.Registry) -> None: """Register required resources to the resource registry.""" super().register_resources(registry) dxf = self.dxf registry.add_layer(dxf.layer) registry.add_linetype(dxf.linetype) registry.add_handle(dxf.get("material_handle")) # unsupported resource attributes: # - visualstyle_handle # - plotstyle_handle def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None: """Translate resources from self to the copied entity.""" super().map_resources(clone, mapping) clone.dxf.layer = mapping.get_layer(self.dxf.layer) attrib_exist = self.dxf.hasattr if attrib_exist("linetype"): clone.dxf.linetype = mapping.get_linetype(self.dxf.linetype) if attrib_exist("material_handle"): clone.dxf.material_handle = mapping.get_handle(self.dxf.material_handle) # unsupported attributes: clone.dxf.discard("visualstyle_handle") clone.dxf.discard("plotstyle_handle") @factory.register_entity class SeqEnd(DXFGraphic): DXFTYPE = "SEQEND" def load_dxf_attribs( self, processor: Optional[SubclassProcessor] = None ) -> DXFNamespace: """Loading interface. (internal API)""" # bypass DXFGraphic, loading proxy graphic is skipped! dxf = super(DXFGraphic, self).load_dxf_attribs(processor) if processor: processor.simple_dxfattribs_loader(dxf, acdb_entity_group_codes) # type: ignore return dxf def add_entity(entity: DXFGraphic, layout: BaseLayout) -> None: """Add `entity` entity to the entity database and to the given `layout`.""" assert entity.dxf.handle is None assert layout is not None if layout.doc: factory.bind(entity, layout.doc) layout.add_entity(entity) def replace_entity(source: DXFGraphic, target: DXFGraphic, layout: BaseLayout) -> None: """Add `target` entity to the entity database and to the given `layout` and replace the `source` entity by the `target` entity. """ assert target.dxf.handle is None assert layout is not None target.dxf.handle = source.dxf.handle if source in layout: layout.delete_entity(source) if layout.doc: factory.bind(target, layout.doc) layout.add_entity(target) else: source.destroy() def is_graphic_entity(entity: DXFEntity) -> TypeGuard[DXFGraphic]: """Returns ``True`` if the `entity` has a graphical representations and can reside in the model space, a paper space or a block layout, otherwise the entity is a table or class entry or a DXF object from the OBJECTS section. """ if isinstance(entity, DXFGraphic): return True if isinstance(entity, DXFTagStorage) and entity.is_graphic_entity: return True return False def get_font_name(entity: DXFEntity) -> str: """Returns the font name of any DXF entity. This function always returns a font name even if the entity doesn't support text styles. The default font name is "txt". """ font_name = const.DEFAULT_TEXT_FONT doc = entity.doc if doc is None: return font_name try: style_name = entity.dxf.get("style", const.DEFAULT_TEXT_STYLE) except const.DXFAttributeError: return font_name try: style = doc.styles.get(style_name) return style.dxf.font except const.DXFTableEntryError: return font_name