refactor: excel parse

This commit is contained in:
Blizzard
2026-04-16 10:01:11 +08:00
parent 680ecc320f
commit f62f95ec02
7941 changed files with 2899112 additions and 0 deletions
@@ -0,0 +1,115 @@
# Copyright (c) 2011-2021 Manfred Moitzi
# License: MIT License
from . import factory
# basic classes
from .xdict import ExtensionDict
from .xdata import XData
from .appdata import AppData, Reactors
from .dxfentity import DXFEntity, DXFTagStorage
from .dxfgfx import DXFGraphic, SeqEnd, is_graphic_entity, get_font_name
from .dxfobj import DXFObject, is_dxf_object
from .dxfns import DXFNamespace, SubclassProcessor
# register management structures
from .dxfclass import DXFClass
from .table import TableHead
# register table entries
from .ltype import Linetype
from .layer import Layer, LayerOverrides
from .textstyle import Textstyle
from .dimstyle import DimStyle
from .view import View
from .vport import VPort
from .ucs import UCSTableEntry
from .appid import AppID
from .blockrecord import BlockRecord
# register DXF objects R2000
from .acad_proxy_entity import ACADProxyEntity
from .dxfobj import XRecord, Placeholder, VBAProject, SortEntsTable
from .dictionary import Dictionary, DictionaryVar, DictionaryWithDefault
from .layout import DXFLayout
from .idbuffer import IDBuffer
from .sun import Sun
from .material import Material, MaterialCollection
from .oleframe import OLE2Frame
from .spatial_filter import SpatialFilter
# register DXF objects R2007
from .visualstyle import VisualStyle
# register entities R12
from .line import Line
from .point import Point
from .circle import Circle
from .arc import Arc
from .shape import Shape
from .solid import Solid, Face3d, Trace
from .text import Text
from .subentity import LinkedEntities, entity_linker
from .insert import Insert
from .block import Block, EndBlk
from .polyline import Polyline, Polyface, Polymesh, MeshVertexCache
from .attrib import Attrib, AttDef, copy_attrib_as_text
from .dimension import *
from .dimstyleoverride import DimStyleOverride
from .viewport import Viewport
# register graphical entities R2000
from .lwpolyline import LWPolyline
from .ellipse import Ellipse
from .xline import XLine, Ray
from .mtext import MText
from .mtext_columns import *
from .spline import Spline
from .mesh import Mesh, MeshData
from .boundary_paths import *
from .gradient import *
from .pattern import *
from .hatch import *
from .mpolygon import MPolygon
from .image import Image, ImageDef, Wipeout
from .underlay import (
Underlay,
UnderlayDefinition,
PdfUnderlay,
DgnUnderlay,
DwfUnderlay,
)
from .leader import Leader
from .tolerance import Tolerance
from .helix import Helix
from .acis import (
Body,
Solid3d,
Region,
Surface,
ExtrudedSurface,
LoftedSurface,
RevolvedSurface,
SweptSurface,
)
from .mline import MLine, MLineVertex, MLineStyle, MLineStyleCollection
from .mleader import MLeader, MLeaderStyle, MLeaderStyleCollection, MultiLeader
# register graphical entities R2004
# register graphical entities R2007
from .light import Light
from .acad_table import (
AcadTableBlockContent,
acad_table_to_block,
read_acad_table_content,
)
# register graphical entities R2010
from .geodata import GeoData
# register graphical entities R2013
# register graphical entities R2018
@@ -0,0 +1,147 @@
# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Iterator
from ezdxf.lldxf import const
from ezdxf.lldxf.tags import Tags
from ezdxf.query import EntityQuery
from .dxfentity import SubclassProcessor
from .dxfgfx import DXFGraphic
from . import factory
from .copy import default_copy, CopyNotSupported
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.entities import DXFNamespace
from ezdxf.layouts import BaseLayout
# Group Codes of AcDbProxyEntity
# https://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-89A690F9-E859-4D57-89EA-750F3FB76C6B
# 100 AcDbProxyEntity
# 90 Proxy entity class ID (always 498)
# 91 Application entity's class ID. Class IDs are based on the order of
# the class in the CLASSES section. The first class is given the ID of
# 500, the next is 501, and so on
#
# 92 Size of graphics data in bytes < R2010; R2010+ = 160
# 310 Binary graphics data (multiple entries can appear) (optional)
#
# 96 Size of unknown data in bytes < R2010; R2010+ = 162
# 311 Binary entity data (multiple entries can appear) (optional)
#
# 93 Size of entity data in bits <R2010; R2010+ = 161
# 310 Binary entity data (multiple entries can appear) (optional)
#
# 330 or 340 or 350 or 360 - An object ID (multiple entries can appear) (optional)
# 94 0 (indicates end of object ID section)
# 95 Object drawing format when it becomes a proxy (a 32-bit unsigned integer):
# Low word is AcDbDwgVersion
# High word is MaintenanceReleaseVersion
# 70 Original custom object data format:
# 0 = DWG format
# 1 = DXF format
@factory.register_entity
class ACADProxyEntity(DXFGraphic):
"""READ ONLY ACAD_PROXY_ENTITY CLASS! DO NOT MODIFY!"""
DXFTYPE = "ACAD_PROXY_ENTITY"
MIN_DXF_VERSION_FOR_EXPORT = const.DXF2000
def __init__(self) -> None:
super().__init__()
self.acdb_proxy_entity: Optional[Tags] = None
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:
self.acdb_proxy_entity = processor.subclass_by_index(2)
self.load_proxy_graphic()
return dxf
def load_proxy_graphic(self) -> None:
if self.acdb_proxy_entity is None:
return
for length_code in (92, 160):
proxy_graphic = load_proxy_data(self.acdb_proxy_entity, length_code, 310)
if proxy_graphic:
self.proxy_graphic = proxy_graphic
return
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
# Proxy graphic is stored in AcDbProxyEntity and not as usual in
# AcDbEntity!
preserve_proxy_graphic = self.proxy_graphic
self.proxy_graphic = None
super().export_dxf(tagwriter)
self.proxy_graphic = preserve_proxy_graphic
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags. (internal API)"""
# Base class and AcDbEntity export is done by parent class
super().export_entity(tagwriter)
if self.acdb_proxy_entity is not None:
tagwriter.write_tags(self.acdb_proxy_entity)
# XDATA export is done by the parent class
def __virtual_entities__(self) -> Iterator[DXFGraphic]:
"""Implements the SupportsVirtualEntities protocol."""
from ezdxf.proxygraphic import ProxyGraphic
if self.proxy_graphic:
for e in ProxyGraphic(self.proxy_graphic, doc=self.doc).virtual_entities():
e.set_source_of_copy(self)
yield e
def virtual_entities(self) -> Iterator[DXFGraphic]:
"""Yields proxy graphic as "virtual" entities."""
return self.__virtual_entities__()
def explode(self, target_layout: Optional[BaseLayout] = None) -> EntityQuery:
"""Explodes the proxy graphic for the ACAD_PROXY_ENTITY into the target layout,
if target layout is ``None``, the layout of the ACAD_PROXY_ENTITY will be used.
This method destroys the source ACAD_PROXY_ENTITY entity.
Args:
target_layout: target layout for exploded entities, ``None`` for
same layout as the source ACAD_PROXY_ENTITY.
Returns:
:class:`~ezdxf.query.EntityQuery` container referencing all exploded
DXF entities.
"""
if target_layout is None:
target_layout = self.get_layout()
if target_layout is None:
raise const.DXFStructureError(
"ACAD_PROXY_ENTITY without layout assignment, specify target layout"
)
entities: list[DXFGraphic] = list(self.__virtual_entities__())
for e in entities:
target_layout.add_entity(e)
self.destroy()
return EntityQuery(entities)
def load_proxy_data(
tags: Tags, length_code: int, data_code: int = 310
) -> Optional[bytes]:
try:
index = tags.tag_index(length_code)
except const.DXFValueError:
return None
binary_data = []
for code, value in tags[index + 1 :]:
if code == data_code:
binary_data.append(value)
else:
break # at first tag with group code != data_code
return b"".join(binary_data)
@@ -0,0 +1,477 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Optional, Iterator
from typing_extensions import Self
import copy
from ezdxf.math import Vec3, Matrix44
from ezdxf.lldxf.tags import Tags, group_tags
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
group_code_mapping,
)
from ezdxf.lldxf import const
from ezdxf.entities import factory
from .dxfentity import base_class, SubclassProcessor, DXFEntity, DXFTagStorage
from .dxfgfx import DXFGraphic, acdb_entity
from .dxfobj import DXFObject
from .objectcollection import ObjectCollection
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.document import Drawing
__all__ = [
"AcadTable",
"AcadTableBlockContent",
"acad_table_to_block",
"read_acad_table_content",
]
acdb_block_reference = DefSubclass(
"AcDbBlockReference",
{
# Block name: an anonymous block begins with a *T value
"geometry": DXFAttr(2),
# Insertion point:
"insert": DXFAttr(10, xtype=XType.point3d, default=Vec3(0, 0, 0)),
},
)
acdb_block_reference_group_codes = group_code_mapping(acdb_block_reference)
acdb_table = DefSubclass(
"AcDbTable",
{
# Table data version number: 0 = 2010
"version": DXFAttr(280),
# Hard of the TABLESTYLE object:
"table_style_id": DXFAttr(342),
# Handle of the associated anonymous BLOCK containing the graphical
# representation:
"block_record_handle": DXFAttr(343),
# Horizontal direction vector:
"horizontal_direction": DXFAttr(11),
# Flag for table value (unsigned integer):
"table_value": DXFAttr(90),
# Number of rows:
"n_rows": DXFAttr(91),
# Number of columns:
"n_cols": DXFAttr(92),
# Flag for an override:
"override_flag": DXFAttr(93),
# Flag for an override of border color:
"border_color_override_flag": DXFAttr(94),
# Flag for an override of border lineweight:
"border_lineweight_override_flag": DXFAttr(95),
# Flag for an override of border visibility:
"border_visibility_override_flag": DXFAttr(96),
# 141: Row height; this value is repeated, 1 value per row
# 142: Column height; this value is repeated, 1 value per column
# for every cell:
# 171: Cell type; this value is repeated, 1 value per cell:
# 1 = text type
# 2 = block type
# 172: Cell flag value; this value is repeated, 1 value per cell
# 173: Cell merged value; this value is repeated, 1 value per cell
# 174: Boolean flag indicating if the autofit option is set for the
# cell; this value is repeated, 1 value per cell
# 175: Cell border width (applicable only for merged cells); this
# value is repeated, 1 value per cell
# 176: Cell border height (applicable for merged cells); this value
# is repeated, 1 value per cell
# 91: Cell override flag; this value is repeated, 1 value per cell
# (from AutoCAD 2007)
# 178: Flag value for a virtual edge
# 145: Rotation value (real; applicable for a block-type cell and
# a text-type cell)
# 344: Hard pointer ID of the FIELD object. This applies only to a
# text-type cell. If the text in the cell contains one or more
# fields, only the ID of the FIELD object is saved.
# The text string (group codes 1 and 3) is ignored
# 1: Text string in a cell. If the string is shorter than 250
# characters, all characters appear in code 1.
# If the string is longer than 250 characters, it is divided
# into chunks of 250 characters.
# The chunks are contained in one or more code 2 codes.
# If code 2 codes are used, the last group is a code 1 and is
# shorter than 250 characters.
# This value applies only to text-type cells and is repeated,
# 1 value per cell
# 2: Text string in a cell, in 250-character chunks; optional.
# This value applies only to text-type cells and is repeated,
# 1 value per cell
# 340: Hard-pointer ID of the block table record.
# This value applies only to block-type cells and is repeated,
# 1 value per cell
# 144: Block scale (real). This value applies only to block-type
# cells and is repeated, 1 value per cell
# 176: Number of attribute definitions in the block table record
# (applicable only to a block-type cell)
# for every ATTDEF:
# 331: Soft pointer ID of the attribute definition in the
# block table record, referenced by group code 179
# (applicable only for a block-type cell). This value is
# repeated once per attribute definition
# 300: Text string value for an attribute definition, repeated
# once per attribute definition and applicable only for
# a block-type cell
# 7: Text style name (string); override applied at the cell level
# 140: Text height value; override applied at the cell level
# 170: Cell alignment value; override applied at the cell level
# 64: Value for the color of cell content; override applied at the
# cell level
# 63: Value for the background (fill) color of cell content;
# override applied at the cell level
# 69: True color value for the top border of the cell;
# override applied at the cell level
# 65: True color value for the right border of the cell;
# override applied at the cell level
# 66: True color value for the bottom border of the cell;
# override applied at the cell level
# 68: True color value for the left border of the cell;
# override applied at the cell level
# 279: Lineweight for the top border of the cell;
# override applied at the cell level
# 275: Lineweight for the right border of the cell;
# override applied at the cell level
# 276: Lineweight for the bottom border of the cell;
# override applied at the cell level
# 278: Lineweight for the left border of the cell;
# override applied at the cell level
# 283: Boolean flag for whether the fill color is on;
# override applied at the cell level
# 289: Boolean flag for the visibility of the top border of the cell;
# override applied at the cell level
# 285: Boolean flag for the visibility of the right border of the cell;
# override applied at the cell level
# 286: Boolean flag for the visibility of the bottom border of the cell;
# override applied at the cell level
# 288: Boolean flag for the visibility of the left border of the cell;
# override applied at the cell level
# 70: Flow direction;
# override applied at the table entity level
# 40: Horizontal cell margin;
# override applied at the table entity level
# 41: Vertical cell margin;
# override applied at the table entity level
# 280: Flag for whether the title is suppressed;
# override applied at the table entity level
# 281: Flag for whether the header row is suppressed;
# override applied at the table entity level
# 7: Text style name (string);
# override applied at the table entity level.
# There may be one entry for each cell type
# 140: Text height (real);
# override applied at the table entity level.
# There may be one entry for each cell type
# 170: Cell alignment (integer);
# override applied at the table entity level.
# There may be one entry for each cell type
# 63: Color value for cell background or for the vertical, left
# border of the table; override applied at the table entity
# level. There may be one entry for each cell type
# 64: Color value for cell content or for the horizontal, top
# border of the table; override applied at the table entity
# level. There may be one entry for each cell type
# 65: Color value for the horizontal, inside border lines;
# override applied at the table entity level
# 66: Color value for the horizontal, bottom border lines;
# override applied at the table entity level
# 68: Color value for the vertical, inside border lines;
# override applied at the table entity level
# 69: Color value for the vertical, right border lines;
# override applied at the table entity level
# 283: Flag for whether background color is enabled (default = 0);
# override applied at the table entity level.
# There may be one entry for each cell type: 0/1 = Disabled/Enabled
# 274-279: Lineweight for each border type of the cell (default = kLnWtByBlock);
# override applied at the table entity level.
# There may be one group for each cell type
# 284-289: Flag for visibility of each border type of the cell (default = 1);
# override applied at the table entity level.
# There may be one group for each cell type: 0/1 = Invisible/Visible
# 97: Standard/title/header row data type
# 98: Standard/title/header row unit type
# 4: Standard/title/header row format string
#
# AutoCAD 2007 and before:
# 177: Cell override flag value (before AutoCAD 2007)
# 92: Extended cell flags (from AutoCAD 2007), COLLISION: group code
# also used by n_cols
# 301: Text string in a cell. If the string is shorter than 250
# characters, all characters appear in code 302.
# If the string is longer than 250 characters, it is divided into
# chunks of 250 characters.
# The chunks are contained in one or more code 303 codes.
# If code 393 codes are used, the last group is a code 1 and is
# shorter than 250 characters.
# --- WRONG: The text is divided into chunks of group code 2 and the last
# chuck has group code 1.
# This value applies only to text-type cells and is repeated,
# 1 value per cell (from AutoCAD 2007)
# 302: Text string in a cell, in 250-character chunks; optional.
# This value applies only to text-type cells and is repeated,
# 302 value per cell (from AutoCAD 2007)
# --- WRONG: 302 contains all the text as a long string, tested with more
# than 66000 characters
# BricsCAD writes long text in cells with both methods: 302 & (2, 2, 2, ..., 1)
#
# REMARK from Autodesk:
# Group code 178 is a flag value for a virtual edge. A virtual edge is
# used when a grid line is shared by two cells.
# For example, if a table contains one row and two columns and it
# contains cell A and cell B, the central grid line
# contains the right edge of cell A and the left edge of cell B.
# One edge is real, and the other edge is virtual.
# The virtual edge points to the real edge; both edges have the same
# set of properties, including color, lineweight, and visibility.
},
)
acdb_table_group_codes = group_code_mapping(acdb_table)
# todo: implement ACAD_TABLE
class AcadTable(DXFGraphic):
"""DXF ACAD_TABLE entity"""
DXFTYPE = "ACAD_TABLE"
DXFATTRIBS = DXFAttributes(
base_class, acdb_entity, acdb_block_reference, acdb_table
)
MIN_DXF_VERSION_FOR_EXPORT = const.DXF2007
def __init__(self):
super().__init__()
self.data = None
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy data."""
assert isinstance(entity, AcadTable)
entity.data = copy.deepcopy(self.data)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_block_reference_group_codes, subclass=2
)
tags = processor.fast_load_dxfattribs(
dxf, acdb_table_group_codes, subclass=3, log=False
)
self.load_table(tags)
return dxf
def load_table(self, tags: Tags):
pass
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_block_reference.name)
self.dxf.export_dxf_attribs(tagwriter, ["geometry", "insert"])
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_table.name)
self.export_table(tagwriter)
def export_table(self, tagwriter: AbstractTagWriter):
pass
def __referenced_blocks__(self) -> Iterable[str]:
"""Support for "ReferencedBlocks" protocol."""
if self.doc:
block_record_handle = self.dxf.get("block_record_handle", None)
if block_record_handle:
return (block_record_handle,)
return tuple()
acdb_table_style = DefSubclass(
"AcDbTableStyle",
{
# Table style version: 0 = 2010
"version": DXFAttr(280),
# Table style description (string; 255 characters maximum):
"name": DXFAttr(3),
# FlowDirection (integer):
# 0 = Down
# 1 = Up
"flow_direction": DXFAttr(7),
# Flags (bit-coded)
"flags": DXFAttr(7),
# Horizontal cell margin (real; default = 0.06)
"horizontal_cell_margin": DXFAttr(40),
# Vertical cell margin (real; default = 0.06)
"vertical_cell_margin": DXFAttr(41),
# Flag for whether the title is suppressed:
# 0/1 = not suppressed/suppressed
"suppress_title": DXFAttr(280),
# Flag for whether the column heading is suppressed:
# 0/1 = not suppressed/suppressed
"suppress_column_header": DXFAttr(281),
# The following group codes are repeated for every cell in the table
# 7: Text style name (string; default = STANDARD)
# 140: Text height (real)
# 170: Cell alignment (integer)
# 62: Text color (integer; default = BYBLOCK)
# 63: Cell fill color (integer; default = 7)
# 283: Flag for whether background color is enabled (default = 0):
# 0/1 = disabled/enabled
# 90: Cell data type
# 91: Cell unit type
# 274-279: Lineweight associated with each border type of the cell
# (default = kLnWtByBlock)
# 284-289: Flag for visibility associated with each border type of the cell
# (default = 1): 0/1 = Invisible/Visible
# 64-69: Color value associated with each border type of the cell
# (default = BYBLOCK)
},
)
# todo: implement TABLESTYLE
class TableStyle(DXFObject):
"""DXF TABLESTYLE entity
Every ACAD_TABLE has its own table style.
Requires DXF version AC1021/R2007
"""
DXFTYPE = "TABLESTYLE"
DXFATTRIBS = DXFAttributes(base_class, acdb_table_style)
MIN_DXF_VERSION_FOR_EXPORT = const.DXF2007
class TableStyleManager(ObjectCollection[TableStyle]):
def __init__(self, doc: Drawing):
super().__init__(doc, dict_name="ACAD_TABLESTYLE", object_type="TABLESTYLE")
@factory.register_entity
class AcadTableBlockContent(DXFTagStorage):
DXFTYPE = "ACAD_TABLE"
DXFATTRIBS = DXFAttributes(
base_class, acdb_entity, acdb_block_reference, acdb_table
)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_block_reference_group_codes, subclass=2
)
processor.fast_load_dxfattribs(
dxf, acdb_table_group_codes, subclass=3, log=False
)
return dxf
def proxy_graphic_content(self) -> Iterable[DXFGraphic]:
return super().__virtual_entities__()
def _block_content(self) -> Iterator[DXFGraphic]:
block_name: str = self.get_block_name()
return self.doc.blocks.get(block_name, []) # type: ignore
def get_block_name(self) -> str:
return self.dxf.get("geometry", "")
def get_insert_location(self) -> Vec3:
return self.dxf.get("insert", Vec3())
def __virtual_entities__(self) -> Iterator[DXFGraphic]:
"""Implements the SupportsVirtualEntities protocol."""
insert: Vec3 = Vec3(self.get_insert_location())
m: Optional[Matrix44] = None
if insert:
# TODO: OCS transformation (extrusion) is ignored yet
m = Matrix44.translate(insert.x, insert.y, insert.z)
for entity in self._block_content():
try:
clone = entity.copy()
except const.DXFTypeError:
continue
if m is not None:
try:
clone.transform(m)
except: # skip entity at any transformation issue
continue
yield clone
def acad_table_to_block(table: DXFEntity) -> None:
"""Converts the given ACAD_TABLE entity to a block references (INSERT entity).
The original ACAD_TABLE entity will be destroyed.
.. versionadded:: 1.1
"""
if not isinstance(table, AcadTableBlockContent):
return
doc = table.doc
owner = table.dxf.owner
block_name = table.get_block_name()
if doc is None or block_name == "" or owner is None:
return
try:
layout = doc.layouts.get_layout_by_key(owner)
except const.DXFKeyError:
return
# replace ACAD_TABLE entity by INSERT entity
layout.add_blockref(
block_name,
insert=table.get_insert_location(),
dxfattribs={"layer": table.dxf.get("layer", "0")},
)
layout.delete_entity(table) # type: ignore
def read_acad_table_content(table: DXFTagStorage) -> list[list[str]]:
"""Returns the content of an ACAD_TABLE entity as list of table rows.
If the count of table rows or table columns is missing the complete content is
stored in the first row.
"""
if table.dxftype() != "ACAD_TABLE":
raise const.DXFTypeError(f"Expected ACAD_TABLE entity, got {str(table)}")
acdb_table = table.xtags.get_subclass("AcDbTable")
nrows = acdb_table.get_first_value(91, 0)
ncols = acdb_table.get_first_value(92, 0)
split_code = 171 # DXF R2004
if acdb_table.has_tag(302):
split_code = 301 # DXF R2007 and later
values = _load_table_values(acdb_table, split_code)
if nrows * ncols == 0:
return [values]
content: list[list[str]] = []
for index in range(nrows):
start = index * ncols
content.append(values[start : start + ncols])
return content
def _load_table_values(tags: Tags, split_code: int) -> list[str]:
values: list[str] = []
for group in group_tags(tags, splitcode=split_code):
g_tags = Tags(group)
if g_tags.has_tag(302): # DXF R2007 and later
# contains all text as one long string, with more than 66000 chars tested
values.append(g_tags.get_first_value(302))
else:
# DXF R2004
# Text is divided into chunks (2, 2, 2, ..., 1) or (3, 3, 3, ..., 1)
# DXF reference says group code 2, BricsCAD writes group code 3
s = "".join(tag.value for tag in g_tags if 1 <= tag.code <= 3)
values.append(s)
return values
@@ -0,0 +1,99 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from ezdxf.entities import XRecord
from ezdxf.lldxf.tags import Tags
from ezdxf.lldxf.types import DXFTag
__all__ = ["RoundtripXRecord"]
SECTION_MARKER_CODE = 102
NOT_FOUND = -1
class RoundtripXRecord:
"""Helper class for ACAD Roundtrip Data.
The data is stored in an XRECORD, in sections separated by tags
(102, "ACAD_SECTION_NAME").
Example for inverted clipping path of SPATIAL_FILTER objects:
...
100
AcDbXrecord
280
1
102
ACAD_INVERTEDCLIP_ROUNDTRIP
10
399.725563048036
20
233.417786599994
30
0.0
...
102
ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE
10
399.725563048036
20
233.417786599994
...
"""
def __init__(self, xrecord: XRecord | None = None) -> None:
if xrecord is None:
xrecord = XRecord()
self.xrecord = xrecord
def has_section(self, key: str) -> bool:
"""Returns True if an entry section for key is present."""
for code, value in self.xrecord.tags:
if code == SECTION_MARKER_CODE and value == key:
return True
return False
def set_section(self, key: str, tags: Tags) -> None:
"""Set content of section `key` to `tags`. Replaces the content of an existing section."""
xrecord_tags = self.xrecord.tags
start, end = find_section(xrecord_tags, key)
if start == NOT_FOUND:
xrecord_tags.append(DXFTag(SECTION_MARKER_CODE, key))
xrecord_tags.extend(tags)
else:
xrecord_tags[start + 1 : end] = tags
def get_section(self, key: str) -> Tags:
"""Returns the content of section `key`."""
xrecord_tags = self.xrecord.tags
start, end = find_section(xrecord_tags, key)
if start != NOT_FOUND:
return xrecord_tags[start + 1 : end]
return Tags()
def discard(self, key: str) -> None:
"""Removes section `key`, section `key` doesn't have to exist."""
xrecord_tags = self.xrecord.tags
start, end = find_section(xrecord_tags, key)
if start != NOT_FOUND:
del xrecord_tags[start:end]
def find_section(tags: Tags, key: str) -> tuple[int, int]:
"""Returns the start- and end index of section `key`.
Returns (-1, -1) if the section does not exist.
"""
start = NOT_FOUND
for index, tag in enumerate(tags):
if tag.code != 102:
continue
if tag.value == key:
start = index
elif start != NOT_FOUND:
return start, index
if start != NOT_FOUND:
return start, len(tags)
return NOT_FOUND, NOT_FOUND
@@ -0,0 +1,837 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Union, Optional, Sequence, Any
from typing_extensions import Self, override
import logging
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
group_code_mapping,
)
from ezdxf.lldxf import const
from ezdxf.lldxf.tags import Tags, DXFTag
from ezdxf.math import Matrix44
from ezdxf.tools import crypt, guid
from ezdxf import msgtypes
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity
from .factory import register_entity
from .copy import default_copy
from .temporary_transform import TransformByBlockReference
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf import xref
__all__ = [
"Body",
"Solid3d",
"Region",
"Surface",
"ExtrudedSurface",
"LoftedSurface",
"RevolvedSurface",
"SweptSurface",
]
logger = logging.getLogger("ezdxf")
acdb_modeler_geometry = DefSubclass(
"AcDbModelerGeometry",
{
"version": DXFAttr(70, default=1),
"flags": DXFAttr(290, dxfversion=const.DXF2013),
"uid": DXFAttr(2, dxfversion=const.DXF2013),
},
)
acdb_modeler_geometry_group_codes = group_code_mapping(acdb_modeler_geometry)
# with R2013/AC1027 Modeler Geometry of ACIS data is stored in the ACDSDATA
# section as binary encoded information detection:
# group code 70, 1, 3 is missing
# group code 290, 2 present
#
# 0
# ACDSRECORD
# 90
# 1
# 2
# AcDbDs::ID
# 280
# 10
# 320
# 19B <<< handle of associated 3DSOLID entity in model space
# 2
# ASM_Data
# 280
# 15
# 94
# 7197 <<< size in bytes ???
# 310
# 414349532042696E61727946696C6...
@register_entity
class Body(DXFGraphic):
"""DXF BODY entity - container entity for embedded ACIS data."""
DXFTYPE = "BODY"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_modeler_geometry)
MIN_DXF_VERSION_FOR_EXPORT = const.DXF2000
def __init__(self) -> None:
super().__init__()
# Store SAT data as immutable sequence of strings, so the data can be shared
# across multiple copies of an ACIS entity.
self._sat: Sequence[str] = tuple()
self._sab: bytes = b""
self._update = False
self._temporary_transformation = TransformByBlockReference()
@property
def acis_data(self) -> Union[bytes, Sequence[str]]:
"""Returns :term:`SAT` data for DXF R2000 up to R2010 and :term:`SAB`
data for DXF R2013 and later
"""
if self.has_binary_data:
return self.sab
return self.sat
@property
def sat(self) -> Sequence[str]:
"""Get/Set :term:`SAT` data as sequence of strings."""
return self._sat
@sat.setter
def sat(self, data: Sequence[str]) -> None:
"""Set :term:`SAT` data as sequence of strings."""
self._sat = tuple(data)
@property
def sab(self) -> bytes:
"""Get/Set :term:`SAB` data as bytes."""
if ( # load SAB data on demand
self.doc is not None and self.has_binary_data and len(self._sab) == 0
):
self._sab = self.doc.acdsdata.get_acis_data(self.dxf.handle)
return self._sab
@sab.setter
def sab(self, data: bytes) -> None:
"""Set :term:`SAB` data as bytes."""
self._update = True
self._sab = data
@property
def has_binary_data(self):
"""Returns ``True`` if the entity contains :term:`SAB` data and
``False`` if the entity contains :term:`SAT` data.
"""
if self.doc:
return self.doc.dxfversion >= const.DXF2013
else:
return False
@override
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, Body)
entity.sat = self.sat
entity.sab = self.sab # load SAB on demand
entity.dxf.uid = guid()
entity._temporary_transformation = self._temporary_transformation
@override
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
super().map_resources(clone, mapping)
clone.convert_acis_data()
def convert_acis_data(self) -> None:
if self.doc is None:
return
msg = ""
dxfversion = self.doc.dxfversion
if dxfversion < const.DXF2013:
if self._sab:
self._sab = b""
msg = "DXF version mismatch, can't convert ACIS data from SAB to SAT, SAB data removed."
else:
if self._sat:
self._sat = tuple()
msg = "DXF version mismatch, can't convert ACIS data from SAT to SAB, SAT data removed."
if msg:
logger.info(msg)
@override
def notify(self, message_type: int, data: Any = None) -> None:
if message_type == msgtypes.COMMIT_PENDING_CHANGES:
self._temporary_transformation.apply_transformation(self)
@override
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
msg = ""
if tagwriter.dxfversion < const.DXF2013:
valid = len(self.sat) > 0
if not valid:
msg = f"{str(self)} doesn't have SAT data, skipping DXF export"
else:
valid = len(self.sab) > 0
if not valid:
msg = f"{str(self)} doesn't have SAB data, skipping DXF export"
if not valid:
logger.info(msg)
if valid and self._temporary_transformation.get_matrix() is not None:
logger.warning(f"{str(self)} has unapplied temporary transformations.")
return valid
@override
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
"""Loading interface. (internal API)"""
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_modeler_geometry_group_codes, 2, log=False
)
if not self.has_binary_data:
self.load_sat_data(processor.subclasses[2])
return dxf
def load_sat_data(self, tags: Tags):
"""Loading interface. (internal API)"""
text_lines = tags2textlines(tag for tag in tags if tag.code in (1, 3))
self._sat = tuple(crypt.decode(text_lines))
@override
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags. (internal API)"""
super().export_entity(tagwriter)
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_modeler_geometry.name)
if tagwriter.dxfversion >= const.DXF2013:
# ACIS data is stored in the ACDSDATA section as SAB
if self.doc and self._update:
# write back changed SAB data into AcDsDataSection or create
# a new ACIS record:
self.doc.acdsdata.set_acis_data(self.dxf.handle, self.sab)
if self.dxf.hasattr("version"):
tagwriter.write_tag2(70, self.dxf.version)
self.dxf.export_dxf_attribs(tagwriter, ["flags", "uid"])
else:
# DXF R2000 - R2010 stores the ACIS data as SAT in the entity
self.dxf.export_dxf_attribs(tagwriter, "version")
self.export_sat_data(tagwriter)
def export_sat_data(self, tagwriter: AbstractTagWriter) -> None:
"""Export ACIS data as DXF tags. (internal API)"""
def cleanup(lines):
for line in lines:
yield line.rstrip().replace("\n", "")
tags = Tags(textlines2tags(crypt.encode(cleanup(self.sat))))
tagwriter.write_tags(tags)
def tostring(self) -> str:
"""Returns ACIS :term:`SAT` data as a single string if the entity has
SAT data.
"""
if self.has_binary_data:
return ""
else:
return "\n".join(self.sat)
@override
def destroy(self) -> None:
if self.has_binary_data:
self.doc.acdsdata.del_acis_data(self.dxf.handle) # type: ignore
super().destroy()
@override
def transform(self, m: Matrix44) -> Self:
self._temporary_transformation.add_matrix(m)
return self
def temporary_transformation(self) -> TransformByBlockReference:
return self._temporary_transformation
def tags2textlines(tags: Iterable) -> Iterable[str]:
"""Yields text lines from code 1 and 3 tags, code 1 starts a line following
code 3 tags are appended to the line.
"""
line = None
for code, value in tags:
if code == 1:
if line is not None:
yield line
line = value
elif code == 3:
line += value
if line is not None:
yield line
def textlines2tags(lines: Iterable[str]) -> Iterable[DXFTag]:
"""Yields text lines as DXFTags, splitting long lines (>255) int code 1
and code 3 tags.
"""
for line in lines:
text = line[:255]
tail = line[255:]
yield DXFTag(1, text)
while len(tail):
text = tail[:255]
tail = tail[255:]
yield DXFTag(3, text)
@register_entity
class Region(Body):
"""DXF REGION entity - container entity for embedded ACIS data."""
DXFTYPE = "REGION"
acdb_3dsolid = DefSubclass(
"AcDb3dSolid",
{
"history_handle": DXFAttr(350, default="0"),
},
)
acdb_3dsolid_group_codes = group_code_mapping(acdb_3dsolid)
@register_entity
class Solid3d(Body):
"""DXF 3DSOLID entity - container entity for embedded ACIS data."""
DXFTYPE = "3DSOLID"
DXFATTRIBS = DXFAttributes(
base_class, acdb_entity, acdb_modeler_geometry, acdb_3dsolid
)
@override
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_3dsolid_group_codes, 3)
return dxf
@override
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
# base class export is done by parent class
super().export_entity(tagwriter)
# AcDbEntity export is done by parent class
# AcDbModelerGeometry export is done by parent class
if tagwriter.dxfversion > const.DXF2004:
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_3dsolid.name)
self.dxf.export_dxf_attribs(tagwriter, "history_handle")
def load_matrix(subclass: Tags, code: int) -> Matrix44:
values = [tag.value for tag in subclass.find_all(code)]
if len(values) != 16:
raise const.DXFStructureError("Invalid transformation matrix.")
return Matrix44(values)
def export_matrix(tagwriter: AbstractTagWriter, code: int, matrix: Matrix44) -> None:
for value in list(matrix):
tagwriter.write_tag2(code, value)
acdb_surface = DefSubclass(
"AcDbSurface",
{
"u_count": DXFAttr(71),
"v_count": DXFAttr(72),
},
)
acdb_surface_group_codes = group_code_mapping(acdb_surface)
@register_entity
class Surface(Body):
"""DXF SURFACE entity - container entity for embedded ACIS data."""
DXFTYPE = "SURFACE"
DXFATTRIBS = DXFAttributes(
base_class, acdb_entity, acdb_modeler_geometry, acdb_surface
)
@override
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_surface_group_codes, 3)
return dxf
@override
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
# base class export is done by parent class
super().export_entity(tagwriter)
# AcDbEntity export is done by parent class
# AcDbModelerGeometry export is done by parent class
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_surface.name)
self.dxf.export_dxf_attribs(tagwriter, ["u_count", "v_count"])
acdb_extruded_surface = DefSubclass(
"AcDbExtrudedSurface",
{
"class_id": DXFAttr(90),
"sweep_vector": DXFAttr(10, xtype=XType.point3d),
# 16x group code 40: Transform matrix of extruded entity (16 floats;
# row major format; default = identity matrix)
"draft_angle": DXFAttr(42, default=0.0), # in radians
"draft_start_distance": DXFAttr(43, default=0.0),
"draft_end_distance": DXFAttr(44, default=0.0),
"twist_angle": DXFAttr(45, default=0.0), # in radians?
"scale_factor": DXFAttr(48, default=0.0),
"align_angle": DXFAttr(49, default=0.0), # in radians
# 16x group code 46: Transform matrix of sweep entity (16 floats;
# row major format; default = identity matrix)
# 16x group code 47: Transform matrix of path entity (16 floats;
# row major format; default = identity matrix)
"solid": DXFAttr(290, default=0), # bool
# 0=No alignment; 1=Align sweep entity to path:
"sweep_alignment_flags": DXFAttr(70, default=0),
"unknown1": DXFAttr(71, default=0),
# 2=Translate sweep entity to path; 3=Translate path to sweep entity:
"align_start": DXFAttr(292, default=0), # bool
"bank": DXFAttr(293, default=0), # bool
"base_point_set": DXFAttr(294, default=0), # bool
"sweep_entity_transform_computed": DXFAttr(295, default=0), # bool
"path_entity_transform_computed": DXFAttr(296, default=0), # bool
"reference_vector_for_controlling_twist": DXFAttr(11, xtype=XType.point3d),
},
)
acdb_extruded_surface_group_codes = group_code_mapping(acdb_extruded_surface)
@register_entity
class ExtrudedSurface(Surface):
"""DXF EXTRUDEDSURFACE entity - container entity for embedded ACIS data."""
DXFTYPE = "EXTRUDEDSURFACE"
DXFATTRIBS = DXFAttributes(
base_class,
acdb_entity,
acdb_modeler_geometry,
acdb_surface,
acdb_extruded_surface,
)
def __init__(self):
super().__init__()
self.transformation_matrix_extruded_entity = Matrix44()
self.sweep_entity_transformation_matrix = Matrix44()
self.path_entity_transformation_matrix = Matrix44()
@override
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, ExtrudedSurface)
super().copy_data(entity, copy_strategy)
entity.transformation_matrix_extruded_entity = (
self.transformation_matrix_extruded_entity.copy()
)
entity.sweep_entity_transformation_matrix = (
self.sweep_entity_transformation_matrix.copy()
)
entity.path_entity_transformation_matrix = (
self.path_entity_transformation_matrix.copy()
)
@override
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_extruded_surface_group_codes, 4, log=False
)
self.load_matrices(processor.subclasses[4])
return dxf
def load_matrices(self, tags: Tags):
self.transformation_matrix_extruded_entity = load_matrix(tags, code=40)
self.sweep_entity_transformation_matrix = load_matrix(tags, code=46)
self.path_entity_transformation_matrix = load_matrix(tags, code=47)
@override
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
# base class export is done by parent class
super().export_entity(tagwriter)
# AcDbEntity export is done by parent class
# AcDbModelerGeometry export is done by parent class
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_extruded_surface.name)
self.dxf.export_dxf_attribs(tagwriter, ["class_id", "sweep_vector"])
export_matrix(
tagwriter,
code=40,
matrix=self.transformation_matrix_extruded_entity,
)
self.dxf.export_dxf_attribs(
tagwriter,
[
"draft_angle",
"draft_start_distance",
"draft_end_distance",
"twist_angle",
"scale_factor",
"align_angle",
],
)
export_matrix(
tagwriter, code=46, matrix=self.sweep_entity_transformation_matrix
)
export_matrix(tagwriter, code=47, matrix=self.path_entity_transformation_matrix)
self.dxf.export_dxf_attribs(
tagwriter,
[
"solid",
"sweep_alignment_flags",
"unknown1",
"align_start",
"bank",
"base_point_set",
"sweep_entity_transform_computed",
"path_entity_transform_computed",
"reference_vector_for_controlling_twist",
],
)
acdb_lofted_surface = DefSubclass(
"AcDbLoftedSurface",
{
# 16x group code 40: Transform matrix of loft entity (16 floats;
# row major format; default = identity matrix)
"plane_normal_lofting_type": DXFAttr(70),
"start_draft_angle": DXFAttr(41, default=0.0), # in radians
"end_draft_angle": DXFAttr(42, default=0.0), # in radians
"start_draft_magnitude": DXFAttr(43, default=0.0),
"end_draft_magnitude": DXFAttr(44, default=0.0),
"arc_length_parameterization": DXFAttr(290, default=0), # bool
"no_twist": DXFAttr(291, default=1), # true/false
"align_direction": DXFAttr(292, default=1), # bool
"simple_surfaces": DXFAttr(293, default=1), # bool
"closed_surfaces": DXFAttr(294, default=0), # bool
"solid": DXFAttr(295, default=0), # true/false
"ruled_surface": DXFAttr(296, default=0), # bool
"virtual_guide": DXFAttr(297, default=0), # bool
},
)
acdb_lofted_surface_group_codes = group_code_mapping(acdb_lofted_surface)
@register_entity
class LoftedSurface(Surface):
"""DXF LOFTEDSURFACE entity - container entity for embedded ACIS data."""
DXFTYPE = "LOFTEDSURFACE"
DXFATTRIBS = DXFAttributes(
base_class,
acdb_entity,
acdb_modeler_geometry,
acdb_surface,
acdb_lofted_surface,
)
def __init__(self):
super().__init__()
self.transformation_matrix_lofted_entity = Matrix44()
@override
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, LoftedSurface)
super().copy_data(entity, copy_strategy)
entity.transformation_matrix_lofted_entity = (
self.transformation_matrix_lofted_entity.copy()
)
@override
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_lofted_surface_group_codes, 4, log=False
)
self.load_matrices(processor.subclasses[4])
return dxf
def load_matrices(self, tags: Tags):
self.transformation_matrix_lofted_entity = load_matrix(tags, code=40)
@override
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
# base class export is done by parent class
super().export_entity(tagwriter)
# AcDbEntity export is done by parent class
# AcDbModelerGeometry export is done by parent class
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_lofted_surface.name)
export_matrix(
tagwriter, code=40, matrix=self.transformation_matrix_lofted_entity
)
self.dxf.export_dxf_attribs(tagwriter, acdb_lofted_surface.attribs.keys())
acdb_revolved_surface = DefSubclass(
"AcDbRevolvedSurface",
{
"class_id": DXFAttr(90, default=0.0),
"axis_point": DXFAttr(10, xtype=XType.point3d),
"axis_vector": DXFAttr(11, xtype=XType.point3d),
"revolve_angle": DXFAttr(40), # in radians
"start_angle": DXFAttr(41), # in radians
# 16x group code 42: Transform matrix of revolved entity (16 floats;
# row major format; default = identity matrix)
"draft_angle": DXFAttr(43), # in radians
"start_draft_distance": DXFAttr(44, default=0),
"end_draft_distance": DXFAttr(45, default=0),
"twist_angle": DXFAttr(46, default=0), # in radians
"solid": DXFAttr(290, default=0), # bool
"close_to_axis": DXFAttr(291, default=0), # bool
},
)
acdb_revolved_surface_group_codes = group_code_mapping(acdb_revolved_surface)
@register_entity
class RevolvedSurface(Surface):
"""DXF REVOLVEDSURFACE entity - container entity for embedded ACIS data."""
DXFTYPE = "REVOLVEDSURFACE"
DXFATTRIBS = DXFAttributes(
base_class,
acdb_entity,
acdb_modeler_geometry,
acdb_surface,
acdb_revolved_surface,
)
def __init__(self):
super().__init__()
self.transformation_matrix_revolved_entity = Matrix44()
@override
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, RevolvedSurface)
super().copy_data(entity, copy_strategy)
entity.transformation_matrix_revolved_entity = (
self.transformation_matrix_revolved_entity.copy()
)
@override
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_revolved_surface_group_codes, 4, log=False
)
self.load_matrices(processor.subclasses[4])
return dxf
def load_matrices(self, tags: Tags):
self.transformation_matrix_revolved_entity = load_matrix(tags, code=42)
@override
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
# base class export is done by parent class
super().export_entity(tagwriter)
# AcDbEntity export is done by parent class
# AcDbModelerGeometry export is done by parent class
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_revolved_surface.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"class_id",
"axis_point",
"axis_vector",
"revolve_angle",
"start_angle",
],
)
export_matrix(
tagwriter,
code=42,
matrix=self.transformation_matrix_revolved_entity,
)
self.dxf.export_dxf_attribs(
tagwriter,
[
"draft_angle",
"start_draft_distance",
"end_draft_distance",
"twist_angle",
"solid",
"close_to_axis",
],
)
acdb_swept_surface = DefSubclass(
"AcDbSweptSurface",
{
"swept_entity_id": DXFAttr(90),
# 90: size of binary data (lost on saving)
# 310: binary data (lost on saving)
"path_entity_id": DXFAttr(91),
# 90: size of binary data (lost on saving)
# 310: binary data (lost on saving)
# 16x group code 40: Transform matrix of sweep entity (16 floats;
# row major format; default = identity matrix)
# 16x group code 41: Transform matrix of path entity (16 floats;
# row major format; default = identity matrix)
"draft_angle": DXFAttr(42), # in radians
"draft_start_distance": DXFAttr(43, default=0),
"draft_end_distance": DXFAttr(44, default=0),
"twist_angle": DXFAttr(45, default=0), # in radians
"scale_factor": DXFAttr(48, default=1),
"align_angle": DXFAttr(49, default=0), # in radians
# don't know the meaning of this matrices
# 16x group code 46: Transform matrix of sweep entity (16 floats;
# row major format; default = identity matrix)
# 16x group code 47: Transform matrix of path entity (16 floats;
# row major format; default = identity matrix)
"solid": DXFAttr(290, default=0), # in radians
# 0=No alignment; 1= align sweep entity to path:
"sweep_alignment": DXFAttr(70, default=0),
"unknown1": DXFAttr(71, default=0),
# 2=Translate sweep entity to path; 3=Translate path to sweep entity:
"align_start": DXFAttr(292, default=0), # bool
"bank": DXFAttr(293, default=0), # bool
"base_point_set": DXFAttr(294, default=0), # bool
"sweep_entity_transform_computed": DXFAttr(295, default=0), # bool
"path_entity_transform_computed": DXFAttr(296, default=0), # bool
"reference_vector_for_controlling_twist": DXFAttr(11, xtype=XType.point3d),
},
)
acdb_swept_surface_group_codes = group_code_mapping(acdb_swept_surface)
@register_entity
class SweptSurface(Surface):
"""DXF SWEPTSURFACE entity - container entity for embedded ACIS data."""
DXFTYPE = "SWEPTSURFACE"
DXFATTRIBS = DXFAttributes(
base_class,
acdb_entity,
acdb_modeler_geometry,
acdb_surface,
acdb_swept_surface,
)
def __init__(self):
super().__init__()
self.transformation_matrix_sweep_entity = Matrix44()
self.transformation_matrix_path_entity = Matrix44()
self.sweep_entity_transformation_matrix = Matrix44()
self.path_entity_transformation_matrix = Matrix44()
@override
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, SweptSurface)
super().copy_data(entity, copy_strategy)
entity.transformation_matrix_sweep_entity = (
self.transformation_matrix_sweep_entity.copy()
)
entity.transformation_matrix_path_entity = (
self.transformation_matrix_path_entity.copy()
)
entity.sweep_entity_transformation_matrix = (
self.sweep_entity_transformation_matrix.copy()
)
entity.path_entity_transformation_matrix = (
self.path_entity_transformation_matrix.copy()
)
@override
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_swept_surface_group_codes, 4, log=False
)
self.load_matrices(processor.subclasses[4])
return dxf
def load_matrices(self, tags: Tags):
self.transformation_matrix_sweep_entity = load_matrix(tags, code=40)
self.transformation_matrix_path_entity = load_matrix(tags, code=41)
self.sweep_entity_transformation_matrix = load_matrix(tags, code=46)
self.path_entity_transformation_matrix = load_matrix(tags, code=47)
@override
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
# base class export is done by parent class
super().export_entity(tagwriter)
# AcDbEntity export is done by parent class
# AcDbModelerGeometry export is done by parent class
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_swept_surface.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"swept_entity_id",
"path_entity_id",
],
)
export_matrix(
tagwriter, code=40, matrix=self.transformation_matrix_sweep_entity
)
export_matrix(tagwriter, code=41, matrix=self.transformation_matrix_path_entity)
self.dxf.export_dxf_attribs(
tagwriter,
[
"draft_angle",
"draft_start_distance",
"draft_end_distance",
"twist_angle",
"scale_factor",
"align_angle",
],
)
export_matrix(
tagwriter, code=46, matrix=self.sweep_entity_transformation_matrix
)
export_matrix(tagwriter, code=47, matrix=self.path_entity_transformation_matrix)
self.dxf.export_dxf_attribs(
tagwriter,
[
"solid",
"sweep_alignment",
"unknown1",
"align_start",
"bank",
"base_point_set",
"sweep_entity_transform_computed",
"path_entity_transform_computed",
"reference_vector_for_controlling_twist",
],
)
@@ -0,0 +1,149 @@
# Copyright (c) 2019-2023 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Sequence, Optional, Iterator
from ezdxf.lldxf.types import dxftag, uniform_appid
from ezdxf.lldxf.tags import Tags
from ezdxf.lldxf.const import DXFKeyError, DXFStructureError
from ezdxf.lldxf.const import (
ACAD_REACTORS,
REACTOR_HANDLE_CODE,
APP_DATA_MARKER,
)
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["AppData", "Reactors"]
ERR_INVALID_DXF_ATTRIB = "Invalid DXF attribute for entity {}"
ERR_DXF_ATTRIB_NOT_EXITS = "DXF attribute {} does not exist"
class AppData:
def __init__(self) -> None:
self.data: dict[str, Tags] = dict()
def __contains__(self, appid: str) -> bool:
"""Returns ``True`` if application-defined data exist for `appid`."""
return uniform_appid(appid) in self.data
def __len__(self) -> int:
"""Returns the count of AppData."""
return len(self.data)
def tags(self) -> Iterable[Tags]:
return self.data.values()
def get(self, appid: str) -> Tags:
"""Get application-defined data for `appid` as
:class:`~ezdxf.lldxf.tags.Tags` container.
The first tag is always (102, "{APPID").
The last tag is always (102, "}").
"""
try:
return self.data[uniform_appid(appid)]
except KeyError:
raise DXFKeyError(appid)
def set(self, tags: Tags) -> None:
"""Store raw application-defined data tags.
The first tag has to be (102, "{APPID").
The last tag has to be (102, "}").
"""
if len(tags):
appid = tags[0].value
self.data[appid] = tags
def add(self, appid: str, data: Iterable[Sequence]) -> None:
"""Add application-defined tags for `appid`.
Adds first tag (102, "{APPID") if not exist.
Adds last tag (102, "}" if not exist.
"""
data = Tags(dxftag(code, value) for code, value in data)
appid = uniform_appid(appid)
if data[0] != (APP_DATA_MARKER, appid):
data.insert(0, dxftag(APP_DATA_MARKER, appid))
if data[-1] != (APP_DATA_MARKER, "}"):
data.append(dxftag(APP_DATA_MARKER, "}"))
self.set(data)
def discard(self, appid: str):
"""Delete application-defined data for `appid` without raising and error
if `appid` doesn't exist.
"""
_appid = uniform_appid(appid)
if _appid in self.data:
del self.data[_appid]
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
for data in self.data.values():
tagwriter.write_tags(data)
class Reactors:
"""Handle storage for related reactors.
Reactors are other objects related to the object that contains this
Reactor() instance.
"""
def __init__(self, handles: Optional[Iterable[str]] = None):
self.reactors: set[str] = set(handles or [])
def __len__(self) -> int:
"""Returns count of registered handles."""
return len(self.reactors)
def __contains__(self, handle: str) -> bool:
"""Returns ``True`` if `handle` is registered."""
return handle in self.reactors
def __iter__(self) -> Iterator[str]:
"""Returns an iterator for all registered handles."""
return iter(self.get())
def copy(self) -> Reactors:
"""Returns a copy."""
return Reactors(self.reactors)
@classmethod
def from_tags(cls, tags: Optional[Tags] = None) -> Reactors:
"""Create Reactors() instance from tags.
Expected DXF structure:
[(102, '{ACAD_REACTORS'), (330, handle), ..., (102, '}')]
Args:
tags: list of DXFTags()
"""
if tags is None:
return cls(None)
if len(tags) < 2: # no reactors are valid
raise DXFStructureError("ACAD_REACTORS error")
return cls((handle.value for handle in tags[1:-1]))
def get(self) -> list[str]:
"""Returns all registered handles as sorted list."""
return sorted(self.reactors, key=lambda x: int(x, base=16))
def set(self, handles: Optional[Iterable[str]]) -> None:
"""Reset all handles."""
self.reactors = set(handles or [])
def add(self, handle: str) -> None:
"""Add a single `handle`."""
self.reactors.add(handle)
def discard(self, handle: str):
"""Discard a single `handle`."""
self.reactors.discard(handle)
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
tagwriter.write_tag2(APP_DATA_MARKER, ACAD_REACTORS)
for handle in self.get():
tagwriter.write_tag2(REACTOR_HANDLE_CODE, handle)
tagwriter.write_tag2(APP_DATA_MARKER, "}")
@@ -0,0 +1,62 @@
# Copyright (c) 2019-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import logging
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
group_code_mapping,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER
from ezdxf.entities.dxfentity import base_class, SubclassProcessor, DXFEntity
from ezdxf.entities.layer import acdb_symbol_table_record
from ezdxf.lldxf.validator import is_valid_table_name
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["AppID"]
logger = logging.getLogger("ezdxf")
acdb_appid = DefSubclass(
"AcDbRegAppTableRecord",
{
"name": DXFAttr(2, validator=is_valid_table_name),
"flags": DXFAttr(70, default=0),
},
)
acdb_appid_group_codes = group_code_mapping(acdb_appid)
@register_entity
class AppID(DXFEntity):
"""DXF APPID entity"""
DXFTYPE = "APPID"
DXFATTRIBS = DXFAttributes(base_class, acdb_symbol_table_record, acdb_appid)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_appid_group_codes, subclass=2
)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
# AcDbEntity export is done by parent class
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_symbol_table_record.name)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_appid.name)
# for all DXF versions
self.dxf.export_dxf_attribs(tagwriter, ["name", "flags"])
@@ -0,0 +1,148 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator
import math
import numpy as np
from ezdxf.math import (
Vec3,
Matrix44,
ConstructionArc,
arc_angle_span_deg,
)
from ezdxf.math.transformtools import OCSTransform
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
group_code_mapping,
merge_group_code_mappings,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER
from .dxfentity import base_class
from .dxfgfx import acdb_entity
from .circle import acdb_circle, Circle, merged_circle_group_codes
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["Arc"]
acdb_arc = DefSubclass(
"AcDbArc",
{
"start_angle": DXFAttr(50, default=0),
"end_angle": DXFAttr(51, default=360),
},
)
acdb_arc_group_codes = group_code_mapping(acdb_arc)
merged_arc_group_codes = merge_group_code_mappings(
merged_circle_group_codes, acdb_arc_group_codes
)
@register_entity
class Arc(Circle):
"""DXF ARC entity"""
DXFTYPE = "ARC"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_circle, acdb_arc)
MERGED_GROUP_CODES = merged_arc_group_codes
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
# AcDbEntity export is done by parent class
# AcDbCircle export is done by parent class
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_arc.name)
self.dxf.export_dxf_attribs(tagwriter, ["start_angle", "end_angle"])
@property
def start_point(self) -> Vec3:
"""Returns the start point of the arc in :ref:`WCS`, takes the :ref:`OCS` into
account.
"""
v = list(self.vertices([self.dxf.start_angle]))
return v[0]
@property
def end_point(self) -> Vec3:
"""Returns the end point of the arc in :ref:`WCS`, takes the :ref:`OCS` into
account.
"""
v = list(self.vertices([self.dxf.end_angle]))
return v[0]
def angles(self, num: int) -> Iterator[float]:
"""Yields `num` angles from start- to end angle in degrees in counter-clockwise
orientation. All angles are normalized in the range from [0, 360).
"""
if num < 2:
raise ValueError("num >= 2")
start = self.dxf.start_angle % 360
stop = self.dxf.end_angle % 360
if stop <= start:
stop += 360
for angle in np.linspace(start, stop, num=num, endpoint=True):
yield angle % 360
def flattening(self, sagitta: float) -> Iterator[Vec3]:
"""Approximate the arc by vertices in :ref:`WCS`, the argument `sagitta`_
defines the maximum distance from the center of an arc segment to the center of
its chord.
.. _sagitta: https://en.wikipedia.org/wiki/Sagitta_(geometry)
"""
arc = self.construction_tool()
ocs = self.ocs()
elevation = Vec3(self.dxf.center).z
if ocs.transform:
to_wcs = ocs.points_to_wcs
else:
to_wcs = Vec3.generate
yield from to_wcs(Vec3(p.x, p.y, elevation) for p in arc.flattening(sagitta))
def transform(self, m: Matrix44) -> Arc:
"""Transform ARC entity by transformation matrix `m` inplace.
Raises ``NonUniformScalingError()`` for non-uniform scaling.
"""
ocs = OCSTransform(self.dxf.extrusion, m)
super()._transform(ocs)
s: float = self.dxf.start_angle
e: float = self.dxf.end_angle
if not math.isclose(arc_angle_span_deg(s, e), 360.0):
(
self.dxf.start_angle,
self.dxf.end_angle,
) = ocs.transform_ccw_arc_angles_deg(s, e)
self.post_transform(m)
return self
def construction_tool(self) -> ConstructionArc:
"""Returns the 2D construction tool :class:`ezdxf.math.ConstructionArc` but the
extrusion vector is ignored.
"""
dxf = self.dxf
return ConstructionArc(
dxf.center,
dxf.radius,
dxf.start_angle,
dxf.end_angle,
)
def apply_construction_tool(self, arc: ConstructionArc) -> Arc:
"""Set ARC data from the construction tool :class:`ezdxf.math.ConstructionArc`
but the extrusion vector is ignored.
"""
dxf = self.dxf
dxf.center = Vec3(arc.center)
dxf.radius = arc.radius
dxf.start_angle = arc.start_angle
dxf.end_angle = arc.end_angle
return self # floating interface
@@ -0,0 +1,737 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing_extensions import Self
import copy
from ezdxf.audit import Auditor, AuditError
from ezdxf.lldxf import validator
from ezdxf.math import NULLVEC, Vec3, Z_AXIS, OCS, Matrix44
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf import const
from ezdxf.lldxf.types import EMBEDDED_OBJ_MARKER, EMBEDDED_OBJ_STR
from ezdxf.enums import MAP_MTEXT_ALIGN_TO_FLAGS, TextHAlign, TextVAlign
from ezdxf.tools import set_flag_state
from ezdxf.tools.text import (
load_mtext_content,
fast_plain_mtext,
plain_mtext,
)
from .dxfns import SubclassProcessor, DXFNamespace
from .dxfentity import base_class
from .dxfgfx import acdb_entity, elevation_to_z_axis
from .text import Text, acdb_text, acdb_text_group_codes
from .mtext import (
acdb_mtext_group_codes,
MText,
export_mtext_content,
acdb_mtext,
)
from .factory import register_entity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.lldxf.tags import Tags
from ezdxf import xref
__all__ = ["AttDef", "Attrib", "copy_attrib_as_text", "BaseAttrib"]
# Where is it valid to place an ATTRIB entity:
# - YES: attached to an INSERT entity
# - NO: stand-alone entity in model space - ignored by BricsCAD and TrueView
# - NO: stand-alone entity in paper space - ignored by BricsCAD and TrueView
# - NO: stand-alone entity in block layout - ignored by BricsCAD and TrueView
#
# The RECOVER command of BricsCAD removes the stand-alone ATTRIB entities:
# "Invalid subentity type AcDbAttribute(<handle>)"
#
# IMPORTANT: placing ATTRIB at an invalid layout does NOT create an invalid DXF file!
#
# Where is it valid to place an ATTDEF entity:
# - NO: attached to an INSERT entity
# - YES: stand-alone entity in a BLOCK layout - BricsCAD and TrueView render the
# TAG in the block editor and does not render the ATTDEF as block content
# for the INSERT entity.
# - YES: stand-alone entity in model space - BricsCAD and TrueView render the
# TAG not the default text - the model space is also a block content
# (XREF, see also INSERT entity)
# - YES: stand-alone entity in paper space - same as model space, although a
# paper space can not be used as XREF.
# DXF Reference for ATTRIB is a total mess and incorrect, the AcDbText subclass
# for the ATTRIB entity is the same as for the TEXT entity, but the valign field
# from the 2nd AcDbText subclass of the TEXT entity is stored in the
# AcDbAttribute subclass:
attrib_fields = {
# "version": DXFAttr(280, default=0, dxfversion=const.DXF2010),
# The "version" tag has the same group code as the lock_position tag!!!!!
# Version number: 0 = 2010
# This tag is not really used (at least by BricsCAD) but there exists DXF files
# which do use this tag: "dxftest\attrib\attrib_with_mtext_R2018.dxf"
# ezdxf stores the last group code 280 as "lock_position" attribute and does
# not export a version tag for any DXF version.
# Tag string (cannot contain spaces):
# Mandatory by AutoCAD!
"tag": DXFAttr(
2,
default="",
validator=validator.is_valid_attrib_tag,
fixer=validator.fix_attrib_tag,
),
# 1 = Attribute is invisible (does not appear)
# 2 = This is a constant attribute
# 4 = Verification is required on input of this attribute
# 8 = Attribute is preset (no prompt during insertion)
"flags": DXFAttr(70, default=0),
# Field length (optional) (not currently used)
"field_length": DXFAttr(73, default=0, optional=True),
# Vertical text justification type (optional); see group code 73 in TEXT
"valign": DXFAttr(
74,
default=0,
optional=True,
validator=validator.is_in_integer_range(0, 4),
fixer=RETURN_DEFAULT,
),
# Lock position flag. Locks the position of the attribute within the block
# reference, example of double use of group codes in one sub class
"lock_position": DXFAttr(
280,
default=0,
dxfversion=const.DXF2007, # tested with BricsCAD 2023/TrueView 2023
optional=True,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Attribute type:
# 1 = single line
# 2 = multiline ATTRIB
# 4 = multiline ATTDEF
"attribute_type": DXFAttr(
71,
default=const.ATTRIB_TYPE_SINGLE_LINE,
dxfversion=const.DXF2018,
optional=True,
validator=validator.is_one_of({1, 2, 4}),
fixer=RETURN_DEFAULT,
),
}
# ATTDEF has an additional field: 'prompt'
# DXF attribute definitions are immutable, a shallow copy is sufficient:
attdef_fields = dict(attrib_fields)
attdef_fields["prompt"] = DXFAttr(
3,
default="",
validator=validator.is_valid_one_line_text,
fixer=validator.fix_one_line_text,
)
acdb_attdef = DefSubclass("AcDbAttributeDefinition", attdef_fields)
acdb_attdef_group_codes = group_code_mapping(acdb_attdef)
acdb_attrib = DefSubclass("AcDbAttribute", attrib_fields)
acdb_attrib_group_codes = group_code_mapping(acdb_attrib)
# --------------------------------------------------------------------------------------
# Does subclass AcDbXrecord really exist? Only the documentation in the DXF reference
# exists, no real world examples seen so far - it wouldn't be the first error or misleading
# information in the DXF reference.
# --------------------------------------------------------------------------------------
# For XRECORD the tag order is important and group codes appear multiple times,
# therefore this attribute definition needs a special treatment!
acdb_attdef_xrecord = DefSubclass(
"AcDbXrecord",
[ # type: ignore
# Duplicate record cloning flag (determines how to merge duplicate entries):
# 1 = Keep existing
("cloning", DXFAttr(280, default=1)),
# MText flag:
# 2 = multiline attribute
# 4 = constant multiline attribute definition
("mtext_flag", DXFAttr(70, default=0)),
# isReallyLocked flag:
# 0 = unlocked
# 1 = locked
(
"really_locked",
DXFAttr(
70,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
),
# Number of secondary attributes or attribute definitions:
("secondary_attribs_count", DXFAttr(70, default=0)),
# Hard-pointer id of secondary attribute(s) or attribute definition(s):
("secondary_attribs_handle", DXFAttr(340, default="0")),
# Alignment point of attribute or attribute definition:
("align_point", DXFAttr(10, xtype=XType.point3d, default=NULLVEC)),
("current_annotation_scale", DXFAttr(40, default=0)),
# attribute or attribute definition tag string
(
"tag",
DXFAttr(
2,
default="",
validator=validator.is_valid_attrib_tag,
fixer=validator.fix_attrib_tag,
),
),
],
)
# Just for documentation:
# The "attached" MTEXT feature most likely does not exist!
#
# A special MTEXT entity can follow the ATTDEF and ATTRIB entity, which starts
# as a usual DXF entity with (0, 'MTEXT'), so processing can't be done here,
# because for ezdxf is this a separated Entity.
#
# The attached MTEXT entity: owner is None and handle is None
# Linked as attribute `attached_mtext`.
# I don't have seen this combination of entities in real world examples and is
# ignored by ezdxf for now.
#
# No DXF files available which uses this feature - misleading DXF Reference!?
# Attrib and Attdef can have embedded MTEXT entities located in the
# <Embedded Object> subclass, see issue #258
class BaseAttrib(Text):
XRECORD_DEF = acdb_attdef_xrecord
def __init__(self) -> None:
super().__init__()
# Does subclass AcDbXrecord really exist?
self._xrecord: Optional[Tags] = None
self._embedded_mtext: Optional[EmbeddedMText] = None
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy entity data, xrecord data and embedded MTEXT are not stored
in the entity database.
"""
assert isinstance(entity, BaseAttrib)
entity._xrecord = copy.deepcopy(self._xrecord)
entity._embedded_mtext = copy.deepcopy(self._embedded_mtext)
def load_embedded_mtext(self, processor: SubclassProcessor) -> None:
if not processor.embedded_objects:
return
embedded_object = processor.embedded_objects[0]
if embedded_object:
mtext = EmbeddedMText()
mtext.load_dxf_tags(processor)
self._embedded_mtext = mtext
def export_dxf_r2018_features(self, tagwriter: AbstractTagWriter) -> None:
tagwriter.write_tag2(71, self.dxf.attribute_type)
tagwriter.write_tag2(72, 0) # unknown tag
if self.dxf.hasattr("align_point"):
# duplicate align point - why?
tagwriter.write_vertex(11, self.dxf.align_point)
if self._xrecord:
tagwriter.write_tags(self._xrecord)
if self._embedded_mtext:
self._embedded_mtext.export_dxf_tags(tagwriter)
@property
def is_const(self) -> bool:
"""This is a constant attribute if ``True``."""
return bool(self.dxf.flags & const.ATTRIB_CONST)
@is_const.setter
def is_const(self, state: bool) -> None:
self.dxf.flags = set_flag_state(self.dxf.flags, const.ATTRIB_CONST, state)
@property
def is_invisible(self) -> bool:
"""Attribute is invisible if ``True``."""
return bool(self.dxf.flags & const.ATTRIB_INVISIBLE)
@is_invisible.setter
def is_invisible(self, state: bool) -> None:
self.dxf.flags = set_flag_state(self.dxf.flags, const.ATTRIB_INVISIBLE, state)
@property
def is_verify(self) -> bool:
"""Verification is required on input of this attribute. (interactive CAD
application feature)
"""
return bool(self.dxf.flags & const.ATTRIB_VERIFY)
@is_verify.setter
def is_verify(self, state: bool) -> None:
self.dxf.flags = set_flag_state(self.dxf.flags, const.ATTRIB_VERIFY, state)
@property
def is_preset(self) -> bool:
"""No prompt during insertion. (interactive CAD application feature)"""
return bool(self.dxf.flags & const.ATTRIB_IS_PRESET)
@is_preset.setter
def is_preset(self, state: bool) -> None:
self.dxf.flags = set_flag_state(self.dxf.flags, const.ATTRIB_IS_PRESET, state)
@property
def has_embedded_mtext_entity(self) -> bool:
"""Returns ``True`` if the entity has an embedded MTEXT entity for multi-line
support.
"""
return bool(self._embedded_mtext)
def virtual_mtext_entity(self) -> MText:
"""Returns the embedded MTEXT entity as a regular but virtual
:class:`MText` entity with the same graphical properties as the
host entity.
"""
if not self._embedded_mtext:
raise TypeError("no embedded MTEXT entity exist")
mtext = self._embedded_mtext.virtual_mtext_entity()
mtext.update_dxf_attribs(self.graphic_properties())
return mtext
def plain_mtext(self, fast=True) -> str:
"""Returns the embedded MTEXT content without formatting codes.
Returns an empty string if no embedded MTEXT entity exist.
The `fast` mode is accurate if the DXF content was created by
reliable (and newer) CAD applications like AutoCAD or BricsCAD.
The `accurate` mode is for some rare cases where the content was
created by older CAD applications or unreliable DXF libraries and CAD
applications.
The `accurate` mode is **much** slower than the `fast` mode.
Args:
fast: uses the `fast` mode to extract the plain MTEXT content if
``True`` or the `accurate` mode if set to ``False``
"""
if self._embedded_mtext:
text = self._embedded_mtext.text
if fast:
return fast_plain_mtext(text, split=False) # type: ignore
else:
return plain_mtext(text, split=False) # type: ignore
return ""
def set_mtext(self, mtext: MText, graphic_properties=True) -> None:
"""Set multi-line properties from a :class:`MText` entity.
The multi-line ATTRIB/ATTDEF entity requires DXF R2018, otherwise an
ordinary single line ATTRIB/ATTDEF entity will be exported.
Args:
mtext: source :class:`MText` entity
graphic_properties: copy graphic properties (color, layer, ...) from
source MTEXT if ``True``
"""
if self._embedded_mtext is None:
self._embedded_mtext = EmbeddedMText()
self._embedded_mtext.set_mtext(mtext)
_update_content_from_mtext(self, mtext)
_update_location_from_mtext(self, mtext)
# set attribute type:
if isinstance(self, Attrib):
attribute_type = const.ATTRIB_TYPE_MULTI_LINE
else:
attribute_type = const.ATTDEF_TYPE_MULTI_LINE
self.dxf.attribute_type = attribute_type
# misc properties
self.dxf.style = mtext.dxf.style
self.dxf.height = mtext.dxf.char_height
self.dxf.discard("width") # controlled in MTEXT by inline codes!
self.dxf.discard("oblique") # controlled in MTEXT by inline codes!
self.dxf.discard("text_generation_flag")
if graphic_properties:
self.update_dxf_attribs(mtext.graphic_properties())
def embed_mtext(self, mtext: MText, graphic_properties=True) -> None:
"""Set multi-line properties from a :class:`MText` entity and destroy the
source entity afterward.
The multi-line ATTRIB/ATTDEF entity requires DXF R2018, otherwise an
ordinary single line ATTRIB/ATTDEF entity will be exported.
Args:
mtext: source :class:`MText` entity
graphic_properties: copy graphic properties (color, layer, ...) from
source MTEXT if ``True``
"""
self.set_mtext(mtext, graphic_properties)
mtext.destroy()
def discard_mtext(self) -> None:
"""Discard multi-line feature.
The embedded MTEXT will be removed and the ATTRIB/ATTDEF will be converted to a
single-line attribute.
"""
self._embedded_mtext = None
self.dxf.attribute_type = const.ATTRIB_TYPE_SINGLE_LINE
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
super().register_resources(registry)
if self._embedded_mtext:
self._embedded_mtext.register_resources(registry)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
assert isinstance(clone, BaseAttrib)
super().map_resources(clone, mapping)
if self._embedded_mtext and clone._embedded_mtext:
self._embedded_mtext.map_resources(clone._embedded_mtext, mapping)
# todo: map handles in embedded XRECORD if a real world example shows up
def transform(self, m: Matrix44) -> Self:
if self._embedded_mtext is None:
super().transform(m)
else:
mtext = self._embedded_mtext.virtual_mtext_entity()
mtext.transform(m)
self.set_mtext(mtext, graphic_properties=False)
self.post_transform(m)
return self
def audit(self, auditor: Auditor) -> None:
"""Validity check."""
super().audit(auditor)
if not self.dxf.hasattr("tag"):
auditor.fixed_error(
code=AuditError.TAG_ATTRIBUTE_MISSING,
message=f'Missing mandatory "tag" attribute, entity {str(self)} deleted.',
)
auditor.trash(self)
def _update_content_from_mtext(text: Text, mtext: MText) -> None:
content = mtext.plain_text(split=True, fast=True)
if content:
# In contrast to AutoCAD, just set the first line as single line
# ATTRIB content. AutoCAD concatenates all lines into a single
# "Line1\PLine2\P...", which (imho) is not very useful.
text.dxf.text = content[0]
def _update_location_from_mtext(text: Text, mtext: MText) -> None:
# TEXT is an OCS entity, MTEXT is a WCS entity
dxf = text.dxf
insert = Vec3(mtext.dxf.insert)
extrusion = Vec3(mtext.dxf.extrusion)
text_direction = mtext.get_text_direction()
if extrusion.isclose(Z_AXIS): # most common case
dxf.rotation = text_direction.angle_deg
else:
ocs = OCS(extrusion)
insert = ocs.from_wcs(insert)
dxf.extrusion = extrusion.normalize()
dxf.rotation = ocs.from_wcs(text_direction).angle_deg
dxf.insert = insert
dxf.align_point = insert # the same point for all MTEXT alignments!
dxf.halign, dxf.valign = MAP_MTEXT_ALIGN_TO_FLAGS.get(
mtext.dxf.attachment_point, (TextHAlign.LEFT, TextVAlign.TOP)
)
@register_entity
class AttDef(BaseAttrib):
"""DXF ATTDEF entity"""
DXFTYPE = "ATTDEF"
# Don't add acdb_attdef_xrecord here:
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_text, acdb_attdef)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super(Text, self).load_dxf_attribs(processor)
# Do not call Text loader.
if processor:
processor.fast_load_dxfattribs(dxf, acdb_text_group_codes, 2, recover=True)
processor.fast_load_dxfattribs(
dxf, acdb_attdef_group_codes, 3, recover=True
)
self._xrecord = processor.find_subclass(self.XRECORD_DEF.name) # type: ignore
self.load_embedded_mtext(processor)
if processor.r12:
# Transform elevation attribute from R11 to z-axis values:
elevation_to_z_axis(dxf, ("insert", "align_point"))
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
# Text() writes 2x AcDbText which is not suitable for AttDef()
self.export_acdb_entity(tagwriter)
self.export_acdb_text(tagwriter)
self.export_acdb_attdef(tagwriter)
if tagwriter.dxfversion >= const.DXF2018:
self.export_dxf_r2018_features(tagwriter)
def export_acdb_attdef(self, tagwriter: AbstractTagWriter) -> None:
if tagwriter.dxfversion > const.DXF12:
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_attdef.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
# write version tag (280, 0) here, if required in the future
"prompt",
"tag",
"flags",
"field_length",
"valign",
"lock_position",
],
)
@register_entity
class Attrib(BaseAttrib):
"""DXF ATTRIB entity"""
DXFTYPE = "ATTRIB"
# Don't add acdb_attdef_xrecord here:
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_text, acdb_attrib)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super(Text, self).load_dxf_attribs(processor)
# Do not call Text loader.
if processor:
processor.fast_load_dxfattribs(dxf, acdb_text_group_codes, 2, recover=True)
processor.fast_load_dxfattribs(
dxf, acdb_attrib_group_codes, 3, recover=True
)
self._xrecord = processor.find_subclass(self.XRECORD_DEF.name) # type: ignore
self.load_embedded_mtext(processor)
if processor.r12:
# Transform elevation attribute from R11 to z-axis values:
elevation_to_z_axis(dxf, ("insert", "align_point"))
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
# Text() writes 2x AcDbText which is not suitable for AttDef()
self.export_acdb_entity(tagwriter)
self.export_acdb_attrib_text(tagwriter)
self.export_acdb_attrib(tagwriter)
if tagwriter.dxfversion >= const.DXF2018:
self.export_dxf_r2018_features(tagwriter)
def export_acdb_attrib_text(self, tagwriter: AbstractTagWriter) -> None:
# Despite the similarities to TEXT, it is different to
# Text.export_acdb_text():
if tagwriter.dxfversion > const.DXF12:
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_text.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"insert",
"height",
"text",
"thickness",
"rotation",
"oblique",
"style",
"width",
"halign",
"align_point",
"text_generation_flag",
"extrusion",
],
)
def export_acdb_attrib(self, tagwriter: AbstractTagWriter) -> None:
if tagwriter.dxfversion > const.DXF12:
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_attrib.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
# write version tag (280, 0) here, if required in the future
"tag",
"flags",
"field_length",
"valign",
"lock_position",
],
)
IGNORE_FROM_ATTRIB = {
"handle",
"owner",
"version",
"prompt",
"tag",
"flags",
"field_length",
"lock_position",
}
def copy_attrib_as_text(attrib: BaseAttrib):
"""Returns the content of the ATTRIB/ATTDEF entity as a new virtual TEXT or
MTEXT entity.
"""
if attrib.has_embedded_mtext_entity:
return attrib.virtual_mtext_entity()
dxfattribs = attrib.dxfattribs(drop=IGNORE_FROM_ATTRIB)
return Text.new(dxfattribs=dxfattribs, doc=attrib.doc)
class EmbeddedMTextNS(DXFNamespace):
_DXFATTRIBS = DXFAttributes(acdb_mtext)
@property
def dxfattribs(self) -> DXFAttributes:
return self._DXFATTRIBS
@property
def dxftype(self) -> str:
return "Embedded MText"
class EmbeddedMText:
"""Representation of the embedded MTEXT object in ATTRIB and ATTDEF.
Introduced in DXF R2018? The DXF reference of the `MTEXT`_ entity
documents only the attached MTEXT entity. The ODA DWG specs includes all
MTEXT attributes of MTEXT starting at group code 10
Stores the required parameters to be shown as as MTEXT.
The AcDbText subclass contains the first line of the embedded MTEXT as
plain text content as group code 1, but this tag seems not to be maintained
if the ATTRIB entity is copied.
Some DXF attributes are duplicated and maintained by the CAD application:
- textstyle: same group code 7 (AcDbText, EmbeddedObject)
- text (char) height: same group code 40 (AcDbText, EmbeddedObject)
.. _MTEXT: https://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-7DD8B495-C3F8-48CD-A766-14F9D7D0DD9B
"""
def __init__(self) -> None:
# Attribute "dxf" contains the DXF attributes defined in subclass
# "AcDbMText"
self.dxf = EmbeddedMTextNS()
self.text: str = ""
def copy(self) -> EmbeddedMText:
copy_ = EmbeddedMText()
copy_.dxf = copy.deepcopy(self.dxf)
return copy_
__copy__ = copy
def load_dxf_tags(self, processor: SubclassProcessor) -> None:
tags = processor.fast_load_dxfattribs(
self.dxf,
group_code_mapping=acdb_mtext_group_codes,
subclass=processor.embedded_objects[0],
recover=False,
)
self.text = load_mtext_content(tags)
def virtual_mtext_entity(self) -> MText:
"""Returns the embedded MTEXT entity as regular but virtual MTEXT
entity. This entity does not have the graphical attributes of the host
entity (ATTRIB/ATTDEF).
"""
mtext = MText.new(dxfattribs=self.dxf.all_existing_dxf_attribs())
mtext.text = self.text
return mtext
def set_mtext(self, mtext: MText) -> None:
"""Set embedded MTEXT attributes from given `mtext` entity."""
self.text = mtext.text
dxf = self.dxf
for k, v in mtext.dxf.all_existing_dxf_attribs().items():
if dxf.is_supported(k):
dxf.set(k, v)
def set_required_dxf_attributes(self):
# These attributes are always present in DXF files created by Autocad:
dxf = self.dxf
for key, default in (
("insert", NULLVEC),
("char_height", 2.5),
("width", 0.0),
("defined_height", 0.0),
("attachment_point", 1),
("flow_direction", 5),
("style", "Standard"),
("line_spacing_style", 1),
("line_spacing_factor", 1.0),
):
if not dxf.hasattr(key):
dxf.set(key, default)
def export_dxf_tags(self, tagwriter: AbstractTagWriter) -> None:
"""Export embedded MTEXT as "Embedded Object"."""
tagwriter.write_tag2(EMBEDDED_OBJ_MARKER, EMBEDDED_OBJ_STR)
self.set_required_dxf_attributes()
self.dxf.export_dxf_attribs(
tagwriter,
[
"insert",
"char_height",
"width",
"defined_height",
"attachment_point",
"flow_direction",
],
)
export_mtext_content(self.text, tagwriter)
self.dxf.export_dxf_attribs(
tagwriter,
[
"style",
"extrusion",
"text_direction",
"rect_width",
"rect_height",
"rotation",
"line_spacing_style",
"line_spacing_factor",
"box_fill_scale",
"bg_fill",
"bg_fill_color",
"bg_fill_true_color",
"bg_fill_color_name",
"bg_fill_transparency",
],
)
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
if self.dxf.hasattr("style"):
registry.add_text_style(self.dxf.style)
def map_resources(self, clone: EmbeddedMText, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
if clone.dxf.hasattr("style"):
clone.dxf.style = mapping.get_text_style(clone.dxf.style)
@@ -0,0 +1,242 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing_extensions import Self
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
merge_group_code_mappings,
)
from ezdxf.lldxf.const import (
SUBCLASS_MARKER,
DXF12,
DXF2000,
MODEL_SPACE_R12,
PAPER_SPACE_R12,
MODEL_SPACE_R2000,
PAPER_SPACE_R2000,
)
from ezdxf.math import NULLVEC
from .dxfentity import base_class, SubclassProcessor, DXFEntity
from .factory import register_entity
from ezdxf.audit import Auditor, AuditError
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf import xref
__all__ = ["Block", "EndBlk"]
acdb_entity = DefSubclass(
"AcDbEntity",
{
# No auto fix for invalid layer names!
"layer": DXFAttr(8, default="0", validator=validator.is_valid_layer_name),
"paperspace": DXFAttr(
67,
default=0,
optional=True,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
},
)
acdb_entity_group_codes = group_code_mapping(acdb_entity)
acdb_block_begin = DefSubclass(
"AcDbBlockBegin",
{
"name": DXFAttr(2, validator=validator.is_valid_block_name),
# The 2nd name with group code 3 is handled internally, and is not an
# explicit DXF attribute.
"description": DXFAttr(4, default="", dxfversion=DXF2000, optional=True),
# Flags:
# 0 = Indicates none of the following flags apply
# 1 = This is an anonymous block generated by hatching, associative
# dimensioning, other internal operations, or an application
# 2 = This block has non-constant attribute definitions (this bit is not set
# if the block has any attribute definitions that are constant, or has
# no attribute definitions at all)
# 4 = This block is an external reference (xref)
# 8 = This block is an xref overlay
# 16 = This block is externally dependent
# 32 = This is a resolved external reference, or dependent of an external
# reference (ignored on input)
# 64 = This definition is a referenced external reference (ignored on input)
"flags": DXFAttr(70, default=0),
"base_point": DXFAttr(10, xtype=XType.any_point, default=NULLVEC),
"xref_path": DXFAttr(1, default=""),
},
)
acdb_block_begin_group_codes = group_code_mapping(acdb_block_begin)
merged_block_begin_group_codes = merge_group_code_mappings(
acdb_entity_group_codes, acdb_block_begin_group_codes
)
MODEL_SPACE_R2000_LOWER = MODEL_SPACE_R2000.lower()
MODEL_SPACE_R12_LOWER = MODEL_SPACE_R12.lower()
PAPER_SPACE_R2000_LOWER = PAPER_SPACE_R2000.lower()
PAPER_SPACE_R12_LOWER = PAPER_SPACE_R12.lower()
@register_entity
class Block(DXFEntity):
"""DXF BLOCK entity"""
DXFTYPE = "BLOCK"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_block_begin)
# Block entity flags:
# This is an anonymous block generated by hatching, associative
# dimensioning, other internal operations, or an application:
ANONYMOUS = 1
# This block has non-constant attribute definitions (this bit is not set
# if the block has any attribute definitions that are constant, or has no
# attribute definitions at all):
NON_CONSTANT_ATTRIBUTES = 2
# This block is an external reference:
XREF = 4
# This block is an xref overlay:
XREF_OVERLAY = 8
# This block is externally dependent:
EXTERNAL = 16
# This is a resolved external reference, or dependent of an external reference:
RESOLVED = 32
# This definition is a referenced external reference:
REFERENCED = 64
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
"""Loading interface. (internal API)"""
dxf = super().load_dxf_attribs(processor)
if processor is None:
return dxf
processor.simple_dxfattribs_loader(dxf, merged_block_begin_group_codes)
if processor.r12:
if dxf.name is None:
dxf.name = ""
name = dxf.name.lower()
if name == MODEL_SPACE_R12_LOWER:
dxf.name = MODEL_SPACE_R2000
elif name == PAPER_SPACE_R12_LOWER:
dxf.name = PAPER_SPACE_R2000
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_entity.name)
if self.dxf.hasattr("paperspace"):
tagwriter.write_tag2(67, 1)
self.dxf.export_dxf_attribs(tagwriter, "layer")
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_block_begin.name)
name = self.dxf.name
if tagwriter.dxfversion == DXF12:
# export modelspace and paperspace with leading '$' instead of '*'
if name.lower() == MODEL_SPACE_R2000_LOWER:
name = MODEL_SPACE_R12
elif name.lower() == PAPER_SPACE_R2000_LOWER:
name = PAPER_SPACE_R12
tagwriter.write_tag2(2, name)
self.dxf.export_dxf_attribs(tagwriter, ["flags", "base_point"])
tagwriter.write_tag2(3, name)
self.dxf.export_dxf_attribs(tagwriter, ["xref_path", "description"])
@property
def is_layout_block(self) -> bool:
"""Returns ``True`` if this is a :class:`~ezdxf.layouts.Modelspace` or
:class:`~ezdxf.layouts.Paperspace` block definition.
"""
name = self.dxf.name.lower()
return name.startswith("*model_space") or name.startswith("*paper_space")
@property
def is_anonymous(self) -> bool:
"""Returns ``True`` if this is an anonymous block generated by
hatching, associative dimensioning, other internal operations, or an
application.
"""
return self.get_flag_state(Block.ANONYMOUS)
@property
def is_xref(self) -> bool:
"""Returns ``True`` if bock is an external referenced file."""
return self.get_flag_state(Block.XREF)
@property
def is_xref_overlay(self) -> bool:
"""Returns ``True`` if bock is an external referenced overlay file."""
return self.get_flag_state(Block.XREF_OVERLAY)
def audit(self, auditor: Auditor):
owner_handle = self.dxf.get("owner")
if owner_handle is None: # invalid owner handle - IGNORE
return
owner = auditor.entitydb.get(owner_handle)
if owner is None: # invalid owner entity - IGNORE
return
owner_name = owner.dxf.get("name", "").upper()
block_name = self.dxf.get("name", "").upper()
if owner_name != block_name:
auditor.add_error(
AuditError.BLOCK_NAME_MISMATCH,
f"{str(self)} name '{block_name}' and {str(owner)} name '{owner_name}' mismatch",
)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
assert isinstance(clone, Block)
super().map_resources(clone, mapping)
clone.dxf.name = mapping.get_block_name(self.dxf.name)
acdb_block_end = DefSubclass("AcDbBlockEnd", {})
@register_entity
class EndBlk(DXFEntity):
"""DXF ENDBLK entity"""
DXFTYPE = "ENDBLK"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_block_end)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
"""Loading interface. (internal API)"""
dxf = super().load_dxf_attribs(processor)
if processor:
processor.simple_dxfattribs_loader(dxf, acdb_entity_group_codes) # type: ignore
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_entity.name)
if self.dxf.hasattr("paperspace"):
tagwriter.write_tag2(67, 1)
self.dxf.export_dxf_attribs(tagwriter, "layer")
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_block_end.name)
@@ -0,0 +1,315 @@
# Copyright (c) 2019-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing_extensions import Self
import logging
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import (
DXF12,
SUBCLASS_MARKER,
DXF2007,
DXFInternalEzdxfError,
)
from ezdxf.entities.dxfentity import base_class, SubclassProcessor, DXFEntity
from ezdxf.entities.layer import acdb_symbol_table_record
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.audit import Auditor
from ezdxf.entities import DXFGraphic, Block, EndBlk
from ezdxf.entities import DXFNamespace
from ezdxf.entitydb import EntitySpace
from ezdxf.layouts import BlockLayout
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf import xref
__all__ = ["BlockRecord"]
logger = logging.getLogger("ezdxf")
acdb_blockrec = DefSubclass(
"AcDbBlockTableRecord",
{
"name": DXFAttr(2, validator=validator.is_valid_block_name),
# handle to associated DXF LAYOUT object
"layout": DXFAttr(340, default="0"),
# 0 = can not explode; 1 = can explode
"explode": DXFAttr(
280,
default=1,
dxfversion=DXF2007,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# 0 = scale non uniformly; 1 = scale uniformly
"scale": DXFAttr(
281,
default=0,
dxfversion=DXF2007,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# see ezdxf/units.py
"units": DXFAttr(
70,
default=0,
dxfversion=DXF2007,
validator=validator.is_in_integer_range(0, 25),
fixer=RETURN_DEFAULT,
),
# 310: Binary data for bitmap preview (optional) - removed (ignored) by ezdxf
},
)
acdb_blockrec_group_codes = group_code_mapping(acdb_blockrec)
# optional handles to existing block references in DXF2000+
# 2: name
# 340: explode
# 102: "{BLKREFS"
# 331: handle to INSERT
# ...
# 102: "}"
# optional XDATA for all DXF versions
# 1000: "ACAD"
# 1001: "DesignCenter Data" (optional)
# 1002: "{"
# 1070: Autodesk Design Center version number
# 1070: Insert units: like 'units'
# 1002: "}"
@register_entity
class BlockRecord(DXFEntity):
"""DXF BLOCK_RECORD table entity
BLOCK_RECORD is the hard owner of all entities in BLOCK definitions, this
means owner tag of entities is handle of BLOCK_RECORD.
"""
DXFTYPE = "BLOCK_RECORD"
DXFATTRIBS = DXFAttributes(base_class, acdb_symbol_table_record, acdb_blockrec)
def __init__(self) -> None:
from ezdxf.entitydb import EntitySpace
super().__init__()
# Store entities in the block_record instead of BlockLayout and Layout,
# because BLOCK_RECORD is also the hard owner of all the entities.
self.entity_space = EntitySpace()
self.block: Optional[Block] = None
self.endblk: Optional[EndBlk] = None
# stores also the block layout structure
self.block_layout: Optional[BlockLayout] = None
def set_block(self, block: Block, endblk: EndBlk):
self.block = block
self.endblk = endblk
self.block.dxf.owner = self.dxf.handle
self.endblk.dxf.owner = self.dxf.handle
def set_entity_space(self, entity_space: EntitySpace) -> None:
self.entity_space = entity_space
def rename(self, name: str) -> None:
assert self.block is not None
self.dxf.name = name
self.block.dxf.name = name
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.simple_dxfattribs_loader(dxf, acdb_blockrec_group_codes) # type: ignore
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
if tagwriter.dxfversion == DXF12:
raise DXFInternalEzdxfError("Exporting BLOCK_RECORDS for DXF R12.")
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_symbol_table_record.name)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_blockrec.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"name",
"layout",
"units",
"explode",
"scale",
],
)
def export_block_definition(self, tagwriter: AbstractTagWriter) -> None:
"""Exports the BLOCK entity, followed by all content entities and finally the
ENDBLK entity, except for the *Model_Space and *Paper_Space blocks, their
entities are stored in the ENTITIES section.
"""
assert self.block is not None
assert self.endblk is not None
if self.block_layout is not None:
self.block_layout.update_block_flags()
self.block.export_dxf(tagwriter)
if not (self.is_modelspace or self.is_active_paperspace):
self.entity_space.export_dxf(tagwriter)
self.endblk.export_dxf(tagwriter)
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
assert self.doc is not None, "BLOCK_RECORD entity must be assigned to document"
assert self.doc.entitydb is not None, "entity database required"
super().register_resources(registry)
key = self.dxf.handle
assert key in self.doc.entitydb, "invalid BLOCK_RECORD handle"
if self.block is not None:
registry.add_entity(self.block, block_key=key)
else:
raise DXFInternalEzdxfError(
f"BLOCK entity in BLOCK_RECORD #{key} is invalid"
)
if self.endblk is not None:
registry.add_entity(self.endblk, block_key=key)
else:
raise DXFInternalEzdxfError(
f"ENDBLK entity in BLOCK_RECORD #{key} is invalid"
)
for e in self.entity_space:
registry.add_entity(e, block_key=key)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
assert isinstance(clone, BlockRecord)
super().map_resources(clone, mapping)
assert self.block is not None
mapping.map_resources_of_copy(self.block)
assert self.endblk is not None
mapping.map_resources_of_copy(self.endblk)
for entity in self.entity_space:
mapping.map_resources_of_copy(entity)
def destroy(self):
"""Destroy associated data:
- BLOCK
- ENDBLK
- all entities stored in this block definition
Does not destroy the linked LAYOUT entity, this is the domain of the
:class:`Layouts` object, which also should initiate the destruction of
'this' BLOCK_RECORD.
"""
if not self.is_alive:
return
self.block.destroy()
self.endblk.destroy()
for entity in self.entity_space:
entity.destroy()
# remove attributes to find invalid access after death
del self.block
del self.endblk
del self.block_layout
super().destroy()
@property
def is_active_paperspace(self) -> bool:
"""``True`` if is "active" paperspace layout."""
return self.dxf.name.lower() == "*paper_space"
@property
def is_any_paperspace(self) -> bool:
"""``True`` if is any kind of paperspace layout."""
return self.dxf.name.lower().startswith("*paper_space")
@property
def is_modelspace(self) -> bool:
"""``True`` if is the modelspace layout."""
return self.dxf.name.lower() == "*model_space"
@property
def is_any_layout(self) -> bool:
"""``True`` if is any kind of modelspace or paperspace layout."""
return self.is_modelspace or self.is_any_paperspace
@property
def is_block_layout(self) -> bool:
"""``True`` if not any kind of modelspace or paperspace layout, just a
regular block definition.
"""
return not self.is_any_layout
@property
def is_xref(self) -> bool:
"""``True`` if represents an XREF (external reference) or XREF_OVERLAY."""
if self.block is not None:
return bool(self.block.dxf.flags & 12)
return False
def add_entity(self, entity: DXFGraphic) -> None:
"""Add an existing DXF entity to BLOCK_RECORD.
Args:
entity: :class:`DXFGraphic`
"""
# assign layout
try:
entity.set_owner(self.dxf.handle, paperspace=int(self.is_any_paperspace))
except AttributeError:
logger.debug(f"Unexpected DXF entity {str(entity)} in {str(self.block)}")
# Add unexpected entities also to the entity space - auditor should fix
# errors!
self.entity_space.add(entity)
def unlink_entity(self, entity: DXFGraphic) -> None:
"""Unlink `entity` from BLOCK_RECORD.
Removes `entity` just from entity space but not from the drawing
database.
Args:
entity: :class:`DXFGraphic`
"""
if entity.is_alive:
self.entity_space.remove(entity)
try:
entity.set_owner(None)
except AttributeError:
pass # unsupported entities as DXFTagStorage
def delete_entity(self, entity: DXFGraphic) -> None:
"""Delete `entity` from BLOCK_RECORD entity space and drawing database.
Args:
entity: :class:`DXFGraphic`
"""
self.unlink_entity(entity) # 1. unlink from entity space
entity.destroy()
def audit(self, auditor: Auditor) -> None:
"""Validity check. (internal API)"""
if not self.is_alive:
return
super().audit(auditor)
self.entity_space.audit(auditor)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,214 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Optional, Iterator
import math
import numpy as np
from ezdxf.lldxf import validator
from ezdxf.math import (
Vec3,
Matrix44,
NULLVEC,
Z_AXIS,
arc_segment_count,
)
from ezdxf.math.transformtools import OCSTransform, NonUniformScalingError
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
merge_group_code_mappings,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER, DXFValueError
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import (
DXFGraphic,
acdb_entity,
add_entity,
replace_entity,
elevation_to_z_axis,
acdb_entity_group_codes,
)
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.entities import Ellipse, Spline
__all__ = ["Circle"]
acdb_circle = DefSubclass(
"AcDbCircle",
{
"center": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
# AutCAD/BricsCAD: Radius is <= 0 is valid
"radius": DXFAttr(40, default=1),
# Elevation is a legacy feature from R11 and prior, do not use this
# attribute, store the entity elevation in the z-axis of the vertices.
# ezdxf does not export the elevation attribute!
"elevation": DXFAttr(38, default=0, optional=True),
"thickness": DXFAttr(39, default=0, optional=True),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
},
)
acdb_circle_group_codes = group_code_mapping(acdb_circle)
merged_circle_group_codes = merge_group_code_mappings(
acdb_entity_group_codes, acdb_circle_group_codes
)
@register_entity
class Circle(DXFGraphic):
"""DXF CIRCLE entity"""
DXFTYPE = "CIRCLE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_circle)
MERGED_GROUP_CODES = merged_circle_group_codes
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, self.MERGED_GROUP_CODES)
if processor.r12:
# Transform elevation attribute from R11 to z-axis values:
elevation_to_z_axis(dxf, ("center",))
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_circle.name)
self.dxf.export_dxf_attribs(
tagwriter, ["center", "radius", "thickness", "extrusion"]
)
def vertices(self, angles: Iterable[float]) -> Iterator[Vec3]:
"""Yields the vertices of the circle of all given `angles` as
:class:`~ezdxf.math.Vec3` instances in :ref:`WCS`.
Args:
angles: iterable of angles in :ref:`OCS` as degrees, angle goes
counter-clockwise around the extrusion vector, and the OCS x-axis
defines 0-degree.
"""
ocs = self.ocs()
radius: float = abs(self.dxf.radius) # AutoCAD ignores the sign too
center = Vec3(self.dxf.center)
for angle in angles:
yield ocs.to_wcs(Vec3.from_deg_angle(angle, radius) + center)
def flattening(self, sagitta: float) -> Iterator[Vec3]:
"""Approximate the circle by vertices in :ref:`WCS` as :class:`~ezdxf.math.Vec3`
instances. The argument `sagitta`_ is the maximum distance from the center of an
arc segment to the center of its chord. Yields a closed polygon where the start
vertex is equal to the end vertex!
.. _sagitta: https://en.wikipedia.org/wiki/Sagitta_(geometry)
"""
radius = abs(self.dxf.radius)
if radius > 0.0:
count = arc_segment_count(radius, math.tau, sagitta)
yield from self.vertices(np.linspace(0.0, 360.0, count + 1))
def transform(self, m: Matrix44) -> Circle:
"""Transform the CIRCLE entity by transformation matrix `m` inplace.
Raises ``NonUniformScalingError()`` for non-uniform scaling.
"""
circle = self._transform(OCSTransform(self.dxf.extrusion, m))
self.post_transform(m)
return circle
def _transform(self, ocs: OCSTransform) -> Circle:
dxf = self.dxf
if ocs.scale_uniform:
dxf.extrusion = ocs.new_extrusion
dxf.center = ocs.transform_vertex(dxf.center)
# old_ocs has a uniform scaled xy-plane, direction of radius-vector
# in the xy-plane is not important, choose x-axis for no reason:
dxf.radius = ocs.transform_length((dxf.radius, 0, 0))
if dxf.hasattr("thickness"):
# thickness vector points in the z-direction of the old_ocs,
# thickness can be negative
dxf.thickness = ocs.transform_thickness(dxf.thickness)
else:
# Caller has to catch this Exception and convert this
# CIRCLE/ARC into an ELLIPSE.
raise NonUniformScalingError(
"CIRCLE/ARC does not support non uniform scaling"
)
return self
def translate(self, dx: float, dy: float, dz: float) -> Circle:
"""Optimized CIRCLE/ARC translation about `dx` in x-axis, `dy` in
y-axis and `dz` in z-axis, returns `self` (floating interface).
"""
ocs = self.ocs()
self.dxf.center = ocs.from_wcs(Vec3(dx, dy, dz) + ocs.to_wcs(self.dxf.center))
# Avoid Matrix44 instantiation if not required:
if self.is_post_transform_required:
self.post_transform(Matrix44.translate(dx, dy, dz))
return self
def to_ellipse(self, replace=True) -> Ellipse:
"""Convert the CIRCLE/ARC entity to an :class:`~ezdxf.entities.Ellipse` entity.
Adds the new ELLIPSE entity to the entity database and to the same layout as
the source entity.
Args:
replace: replace (delete) source entity by ELLIPSE entity if ``True``
"""
from ezdxf.entities import Ellipse
layout = self.get_layout()
if layout is None:
raise DXFValueError("valid layout required")
ellipse = Ellipse.from_arc(self)
if replace:
replace_entity(self, ellipse, layout)
else:
add_entity(ellipse, layout)
return ellipse
def to_spline(self, replace=True) -> Spline:
"""Convert the CIRCLE/ARC entity to a :class:`~ezdxf.entities.Spline` entity.
Adds the new SPLINE entity to the entity database and to the same layout as the
source entity.
Args:
replace: replace (delete) source entity by SPLINE entity if ``True``
"""
from ezdxf.entities import Spline
layout = self.get_layout()
if layout is None:
raise DXFValueError("valid layout required")
spline = Spline.from_arc(self)
if replace:
replace_entity(self, spline, layout)
else:
add_entity(spline, layout)
return spline
@@ -0,0 +1,102 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar, NamedTuple, Optional
from copy import deepcopy
import logging
from ezdxf.lldxf.const import DXFError
if TYPE_CHECKING:
from ezdxf.entities import DXFEntity
__all__ = ["CopyStrategy", "CopySettings", "CopyNotSupported", "default_copy"]
T = TypeVar("T", bound="DXFEntity")
class CopyNotSupported(DXFError):
pass
class CopySettings(NamedTuple):
reset_handles: bool = True
copy_extension_dict: bool = True
copy_xdata: bool = True
copy_appdata: bool = True
copy_reactors: bool = False
copy_proxy_graphic: bool = True
set_source_of_copy: bool = True
# The processing of copy errors of linked entities has to be done in the
# copy_data() method by the entity itself!
ignore_copy_errors_in_linked_entities: bool = True
class LogMessage(NamedTuple):
message: str
level: int = logging.WARNING
entity: Optional[DXFEntity] = None
class CopyStrategy:
log: list[LogMessage] = []
def __init__(self, settings: CopySettings) -> None:
self.settings = settings
def copy(self, entity: T) -> T:
"""Entity copy for usage in the same document or as virtual entity.
This copy is NOT stored in the entity database and does NOT reside in any
layout, block, table or objects section!
"""
settings = self.settings
clone = entity.__class__()
doc = entity.doc
clone.doc = doc
clone.dxf = entity.dxf.copy(clone)
if settings.reset_handles:
clone.dxf.reset_handles()
if settings.copy_extension_dict:
xdict = entity.extension_dict
if xdict is not None and doc is not None and xdict.is_alive:
# Error handling of unsupported entities in the extension dictionary is
# done by the underlying DICTIONARY entity.
clone.extension_dict = xdict.copy(self)
if settings.copy_reactors and entity.reactors is not None:
clone.reactors = entity.reactors.copy()
if settings.copy_proxy_graphic:
clone.proxy_graphic = entity.proxy_graphic # immutable bytes
# if appdata contains handles, they are treated as shared resources
if settings.copy_appdata:
clone.appdata = deepcopy(entity.appdata)
# if xdata contains handles, they are treated as shared resources
if settings.copy_xdata:
clone.xdata = deepcopy(entity.xdata)
if settings.set_source_of_copy:
clone.set_source_of_copy(entity)
entity.copy_data(clone, copy_strategy=self)
return clone
@classmethod
def add_log_message(
cls, msg: str, level: int = logging.WARNING, entity: Optional[DXFEntity] = None
) -> None:
cls.log.append(LogMessage(msg, level, entity))
@classmethod
def clear_log_message(cls) -> None:
cls.log.clear()
# same strategy as DXFEntity.copy() of v1.1.3
default_copy = CopyStrategy(CopySettings())
@@ -0,0 +1,696 @@
# Copyright (c) 2019-2025, Manfred Moitzi
# License: MIT-License
from __future__ import annotations
from typing import TYPE_CHECKING, Union, Optional
from typing_extensions import Self
import logging
from ezdxf.lldxf import validator
from ezdxf.lldxf.const import (
SUBCLASS_MARKER,
DXFKeyError,
DXFValueError,
DXFTypeError,
DXFStructureError,
)
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.types import is_valid_handle
from ezdxf.audit import AuditError
from ezdxf.entities import factory, DXFGraphic
from .dxfentity import base_class, SubclassProcessor, DXFEntity
from .dxfobj import DXFObject
from .copy import default_copy, CopyNotSupported
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace, XRecord
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.document import Drawing
from ezdxf.audit import Auditor
from ezdxf import xref
__all__ = ["Dictionary", "DictionaryWithDefault", "DictionaryVar"]
logger = logging.getLogger("ezdxf")
acdb_dictionary = DefSubclass(
"AcDbDictionary",
{
# DXF Reference: 280 - Hard-owner flag.
# If set to 1, indicates that elements of the dictionary are to be treated as
# hard-owned
# No definition of the default state in the DXF reference!
#
# 2025-04-27:
# AutoCAD creates the root DICTIONARY and the top level DICTIONARY entries
# without group code 280 tags.
# Extension dicts are created with the hard_owned flag set to 1.
# See exploration/dict-analyzer.py
# Conclusion: default state is 0
"hard_owned": DXFAttr(
280,
# 2025-04-27: changed to 0
default=0,
# 2024-11-18: changed to False because of issue #1203
# 2025-04-09: changed back to True because of issue #1279
optional=True,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Duplicate record cloning flag (determines how to merge duplicate entries):
# 0 = not applicable
# 1 = keep existing
# 2 = use clone
# 3 = <xref>$0$<name>
# 4 = $0$<name>
# 5 = Unmangle name
"cloning": DXFAttr(
281,
default=1,
validator=validator.is_in_integer_range(0, 6),
fixer=RETURN_DEFAULT,
),
# 3: entry name
# 350: entry handle, some DICTIONARY objects have 360 as handle group code,
# this is accepted by AutoCAD but not documented by the DXF reference!
# ezdxf replaces group code 360 by 350.
# - group code 350 is a soft-owner handle
# - group code 360 is a hard-owner handle
},
)
acdb_dictionary_group_codes = group_code_mapping(acdb_dictionary)
KEY_CODE = 3
VALUE_CODE = 350
# Some DICTIONARY use group code 360:
SEARCH_CODES = (VALUE_CODE, 360)
@factory.register_entity
class Dictionary(DXFObject):
"""AutoCAD maintains items such as mline styles and group definitions as
objects in dictionaries. Other applications are free to create and use
their own dictionaries as they see fit. The prefix "ACAD_" is reserved
for use by AutoCAD applications.
Dictionary entries are (key, DXFEntity) pairs. DXFEntity could be a string,
because at loading time not all objects are already stored in the EntityDB,
and have to be acquired later.
"""
DXFTYPE = "DICTIONARY"
DXFATTRIBS = DXFAttributes(base_class, acdb_dictionary)
def __init__(self) -> None:
super().__init__()
self._data: dict[str, Union[str, DXFObject]] = dict()
self._value_code = VALUE_CODE
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy hard owned entities but do not store the copies in the entity
database, this is a second step (factory.bind), this is just real copying.
"""
assert isinstance(entity, Dictionary)
entity._value_code = self._value_code
if self.dxf.hard_owned:
# Reactors are removed from the cloned DXF objects.
data: dict[str, DXFEntity] = dict()
for key, ent in self.items():
# ignore strings and None - these entities do not exist
# in the entity database
if isinstance(ent, DXFEntity):
try: # todo: follow CopyStrategy.ignore_copy_errors_in_linked entities
data[key] = ent.copy(copy_strategy=copy_strategy)
except CopyNotSupported:
if copy_strategy.settings.ignore_copy_errors_in_linked_entities:
logger.warning(
f"copy process ignored {str(ent)} - this may cause problems in AutoCAD"
)
else:
raise
entity._data = data # type: ignore
else:
entity._data = dict(self._data)
def get_handle_mapping(self, clone: Dictionary) -> dict[str, str]:
"""Returns handle mapping for in-object copies."""
handle_mapping: dict[str, str] = dict()
if not self.is_hard_owner:
return handle_mapping
for key, entity in self.items():
if not isinstance(entity, DXFEntity):
continue
copied_entry = clone.get(key)
if copied_entry:
handle_mapping[entity.dxf.handle] = copied_entry.dxf.handle
return handle_mapping
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
assert isinstance(clone, Dictionary)
super().map_resources(clone, mapping)
if self.is_hard_owner:
return
data = dict()
for key, entity in self.items():
if not isinstance(entity, DXFEntity):
continue
entity_copy = mapping.get_reference_of_copy(entity.dxf.handle)
if entity_copy:
data[key] = entity
clone._data = data # type: ignore
def del_source_of_copy(self) -> None:
super().del_source_of_copy()
for _, entity in self.items():
if isinstance(entity, DXFEntity) and entity.is_alive:
entity.del_source_of_copy()
def post_bind_hook(self) -> None:
"""Called by binding a new or copied dictionary to the document,
bind hard owned sub-entities to the same document and add them to the
objects section.
"""
if not self.dxf.hard_owned:
return
# copied or new dictionary:
doc = self.doc
assert doc is not None
object_section = doc.objects
owner_handle = self.dxf.handle
for _, entity in self.items():
entity.dxf.owner = owner_handle
factory.bind(entity, doc)
# For a correct DXF export add entities to the objects section:
object_section.add_object(entity)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.fast_load_dxfattribs(
dxf, acdb_dictionary_group_codes, 1, log=False
)
self.load_dict(tags)
return dxf
def load_dict(self, tags):
entry_handle = None
dict_key = None
value_code = VALUE_CODE
for code, value in tags:
if code in SEARCH_CODES:
# First store handles, because at this point, NOT all objects
# are stored in the EntityDB, at first access convert the handle
# to a DXFEntity object.
value_code = code
entry_handle = value
elif code == KEY_CODE:
dict_key = value
if dict_key and entry_handle:
# Store entity as handle string:
self._data[dict_key] = entry_handle
entry_handle = None
dict_key = None
# Use same value code as loaded:
self._value_code = value_code
def post_load_hook(self, doc: Drawing) -> None:
super().post_load_hook(doc)
db = doc.entitydb
def items():
for key, handle in self.items():
entity = db.get(handle)
if entity is not None and entity.is_alive:
yield key, entity
if len(self):
for k, v in list(items()):
self.__setitem__(k, v)
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dictionary.name)
self.dxf.export_dxf_attribs(tagwriter, ["hard_owned", "cloning"])
self.export_dict(tagwriter)
def export_dict(self, tagwriter: AbstractTagWriter):
# key: dict key string
# value: DXFEntity or handle as string
# Ignore invalid handles at export, because removing can create an empty
# dictionary, which is more a problem for AutoCAD than invalid handles,
# and removing the whole dictionary is maybe also a problem.
for key, value in self._data.items():
tagwriter.write_tag2(KEY_CODE, key)
# Value can be a handle string or a DXFEntity object:
if isinstance(value, DXFEntity):
if value.is_alive:
value = value.dxf.handle
else:
logger.debug(
f'Key "{key}" points to a destroyed entity '
f'in {str(self)}, target replaced by "0" handle.'
)
value = "0"
# Use same value code as loaded:
tagwriter.write_tag2(self._value_code, value)
@property
def is_hard_owner(self) -> bool:
"""Returns ``True`` if the dictionary is hard owner of entities.
Hard owned entities will be destroyed by deleting the dictionary.
"""
return bool(self.dxf.hard_owned)
def keys(self):
"""Returns a :class:`KeysView` of all dictionary keys."""
return self._data.keys()
def items(self):
"""Returns an :class:`ItemsView` for all dictionary entries as
(key, entity) pairs. An entity can be a handle string if the entity
does not exist.
"""
for key in self.keys():
yield key, self.get(key) # maybe handle -> DXFEntity
def __getitem__(self, key: str) -> DXFEntity:
"""Return self[`key`].
The returned value can be a handle string if the entity does not exist.
Raises:
DXFKeyError: `key` does not exist
"""
if key in self._data:
return self._data[key] # type: ignore
else:
raise DXFKeyError(key)
def __setitem__(self, key: str, entity: DXFObject) -> None:
"""Set self[`key`] = `entity`.
Only DXF objects stored in the OBJECTS section are allowed as content
of :class:`Dictionary` objects. DXF entities stored in layouts are not
allowed.
Raises:
DXFTypeError: invalid DXF type
"""
return self.add(key, entity)
def __delitem__(self, key: str) -> None:
"""Delete self[`key`].
Raises:
DXFKeyError: `key` does not exist
"""
return self.remove(key)
def __contains__(self, key: str) -> bool:
"""Returns `key` ``in`` self."""
return key in self._data
def __len__(self) -> int:
"""Returns count of dictionary entries."""
return len(self._data)
count = __len__
def get(self, key: str, default: Optional[DXFObject] = None) -> Optional[DXFObject]:
"""Returns the :class:`DXFEntity` for `key`, if `key` exist else
`default`. An entity can be a handle string if the entity
does not exist.
"""
return self._data.get(key, default) # type: ignore
def find_key(self, entity: DXFEntity) -> str:
"""Returns the DICTIONARY key string for `entity` or an empty string if not
found.
"""
for key, entry in self._data.items():
if entry is entity:
return key
return ""
def add(self, key: str, entity: DXFObject) -> None:
"""Add entry (key, value).
If the DICTIONARY is hard owner of its entries, the :meth:`add` does NOT take
ownership of the entity automatically.
Raises:
DXFValueError: invalid entity handle
DXFTypeError: invalid DXF type
"""
if isinstance(entity, str):
if not is_valid_handle(entity):
raise DXFValueError(f"Invalid entity handle #{entity} for key {key}")
elif isinstance(entity, DXFGraphic):
if self.doc is not None and self.doc.is_loading: # type: ignore
# AutoCAD add-ons can store graphical entities in DICTIONARIES
# in the OBJECTS section and AutoCAD does not complain - so just
# preserve them!
# Example "ZJMC-288.dxf" in issue #585, add-on: "acdgnlsdraw.crx"?
logger.warning(f"Invalid entity {str(entity)} in {str(self)}")
else:
# Do not allow ezdxf users to add graphical entities to a
# DICTIONARY object!
raise DXFTypeError(f"Graphic entities not allowed: {entity.dxftype()}")
self._data[key] = entity
def take_ownership(self, key: str, entity: DXFObject):
"""Add entry (key, value) and take ownership."""
self.add(key, entity)
entity.dxf.owner = self.dxf.handle
def remove(self, key: str) -> None:
"""Delete entry `key`. Raises :class:`DXFKeyError`, if `key` does not
exist. Destroys hard owned DXF entities.
"""
data = self._data
if key not in data:
raise DXFKeyError(key)
if self.is_hard_owner:
assert self.doc is not None
entity = self.__getitem__(key)
# Presumption: hard owned DXF objects always reside in the OBJECTS
# section.
self.doc.objects.delete_entity(entity) # type: ignore
del data[key]
def discard(self, key: str) -> None:
"""Delete entry `key` if exists. Does not raise an exception if `key`
doesn't exist and does not destroy hard owned DXF entities.
"""
try:
del self._data[key]
except KeyError:
pass
def clear(self) -> None:
"""Delete all entries from the dictionary and destroys hard owned
DXF entities.
"""
if self.is_hard_owner:
self._delete_hard_owned_entries()
self._data.clear()
def _delete_hard_owned_entries(self) -> None:
# Presumption: hard owned DXF objects always reside in the OBJECTS section
objects = self.doc.objects # type: ignore
for key, entity in self.items():
if isinstance(entity, DXFEntity):
objects.delete_entity(entity) # type: ignore
def add_new_dict(self, key: str, hard_owned: bool = False) -> Dictionary:
"""Create a new sub-dictionary of type :class:`Dictionary`.
Args:
key: name of the sub-dictionary
hard_owned: entries of the new dictionary are hard owned
"""
dxf_dict = self.doc.objects.add_dictionary( # type: ignore
owner=self.dxf.handle, hard_owned=hard_owned
)
self.add(key, dxf_dict)
return dxf_dict
def add_dict_var(self, key: str, value: str) -> DictionaryVar:
"""Add a new :class:`DictionaryVar`.
Args:
key: entry name as string
value: entry value as string
"""
new_var = self.doc.objects.add_dictionary_var( # type: ignore
owner=self.dxf.handle, value=value
)
self.add(key, new_var)
return new_var
def add_xrecord(self, key: str) -> XRecord:
"""Add a new :class:`XRecord`.
Args:
key: entry name as string
"""
new_xrecord = self.doc.objects.add_xrecord( # type: ignore
owner=self.dxf.handle,
)
self.add(key, new_xrecord)
return new_xrecord
def set_or_add_dict_var(self, key: str, value: str) -> DictionaryVar:
"""Set or add new :class:`DictionaryVar`.
Args:
key: entry name as string
value: entry value as string
"""
if key not in self:
dict_var = self.doc.objects.add_dictionary_var( # type: ignore
owner=self.dxf.handle, value=value
)
self.add(key, dict_var)
else:
dict_var = self.get(key)
dict_var.dxf.value = str(value) # type: ignore
return dict_var
def link_dxf_object(self, name: str, obj: DXFObject) -> None:
"""Add `obj` and set owner of `obj` to this dictionary.
Graphical DXF entities have to reside in a layout and therefore can not
be owned by a :class:`Dictionary`.
Raises:
DXFTypeError: `obj` has invalid DXF type
"""
if not isinstance(obj, DXFObject):
raise DXFTypeError(f"invalid DXF type: {obj.dxftype()}")
self.add(name, obj)
obj.dxf.owner = self.dxf.handle
def get_required_dict(self, key: str, hard_owned=False) -> Dictionary:
"""Get entry `key` or create a new :class:`Dictionary`,
if `Key` not exist.
"""
dxf_dict = self.get(key)
if dxf_dict is None:
dxf_dict = self.add_new_dict(key, hard_owned=hard_owned)
elif not isinstance(dxf_dict, Dictionary):
raise DXFStructureError(
f"expected a DICTIONARY entity, got {str(dxf_dict)} for key: {key}"
)
return dxf_dict
def audit(self, auditor: Auditor) -> None:
if not self.is_alive:
return
super().audit(auditor)
self._remove_keys_to_missing_entities(auditor)
def _remove_keys_to_missing_entities(self, auditor: Auditor):
trash: list[str] = []
append = trash.append
db = auditor.entitydb
for key, entry in self._data.items():
if isinstance(entry, str):
if entry not in db:
append(key)
elif entry.is_alive:
if entry.dxf.handle not in db:
append(key)
continue
else: # entity is destroyed, remove key
append(key)
for key in trash:
del self._data[key]
auditor.fixed_error(
code=AuditError.INVALID_DICTIONARY_ENTRY,
message=f'Removed entry "{key}" with invalid handle in {str(self)}',
dxf_entity=self,
data=key,
)
def destroy(self) -> None:
if not self.is_alive:
return
if self.is_hard_owner:
self._delete_hard_owned_entries()
super().destroy()
acdb_dict_with_default = DefSubclass(
"AcDbDictionaryWithDefault",
{
"default": DXFAttr(340),
},
)
acdb_dict_with_default_group_codes = group_code_mapping(acdb_dict_with_default)
@factory.register_entity
class DictionaryWithDefault(Dictionary):
DXFTYPE = "ACDBDICTIONARYWDFLT"
DXFATTRIBS = DXFAttributes(base_class, acdb_dictionary, acdb_dict_with_default)
def __init__(self) -> None:
super().__init__()
self._default: Optional[DXFObject] = None
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
super().copy_data(entity, copy_strategy=copy_strategy)
assert isinstance(entity, DictionaryWithDefault)
entity._default = self._default
def post_load_hook(self, doc: Drawing) -> None:
# Set _default to None if default object not exist - audit() replaces
# a not existing default object by a placeholder object.
# AutoCAD ignores not existing default objects!
self._default = doc.entitydb.get(self.dxf.default) # type: ignore
super().post_load_hook(doc)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_dict_with_default_group_codes, 2)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dict_with_default.name)
self.dxf.export_dxf_attribs(tagwriter, "default")
def __getitem__(self, key: str):
return self.get(key)
def get(self, key: str, default: Optional[DXFObject] = None) -> Optional[DXFObject]:
# `default` argument is ignored, exist only for API compatibility,
"""Returns :class:`DXFEntity` for `key` or the predefined dictionary
wide :attr:`dxf.default` entity if `key` does not exist or ``None``
if default value also not exist.
"""
return super().get(key, default=self._default)
def set_default(self, default: DXFObject) -> None:
"""Set dictionary wide default entry.
Args:
default: default entry as :class:`DXFEntity`
"""
self._default = default
self.dxf.default = self._default.dxf.handle
def audit(self, auditor: Auditor) -> None:
def create_missing_default_object():
placeholder = self.doc.objects.add_placeholder(owner=self.dxf.handle)
self.set_default(placeholder)
auditor.fixed_error(
code=AuditError.CREATED_MISSING_OBJECT,
message=f"Created missing default object in {str(self)}.",
)
if self._default is None or not self._default.is_alive:
if auditor.entitydb.locked:
auditor.add_post_audit_job(create_missing_default_object)
else:
create_missing_default_object()
super().audit(auditor)
acdb_dict_var = DefSubclass(
"DictionaryVariables",
{
"schema": DXFAttr(280, default=0),
# Object schema number (currently set to 0)
"value": DXFAttr(1, default=""),
},
)
acdb_dict_var_group_codes = group_code_mapping(acdb_dict_var)
@factory.register_entity
class DictionaryVar(DXFObject):
"""
DICTIONARYVAR objects are used by AutoCAD as a means to store named values
in the database for setvar / getvar purposes without the need to add entries
to the DXF HEADER section. System variables that are stored as
DICTIONARYVAR objects are the following:
- DEFAULTVIEWCATEGORY
- DIMADEC
- DIMASSOC
- DIMDSEP
- DRAWORDERCTL
- FIELDEVAL
- HALOGAP
- HIDETEXT
- INDEXCTL
- INDEXCTL
- INTERSECTIONCOLOR
- INTERSECTIONDISPLAY
- MSOLESCALE
- OBSCOLOR
- OBSLTYPE
- OLEFRAME
- PROJECTNAME
- SORTENTS
- UPDATETHUMBNAIL
- XCLIPFRAME
- XCLIPFRAME
"""
DXFTYPE = "DICTIONARYVAR"
DXFATTRIBS = DXFAttributes(base_class, acdb_dict_var)
@property
def value(self) -> str:
"""Get/set the value of the :class:`DictionaryVar` as string."""
return self.dxf.get("value", "")
@value.setter
def value(self, data: str) -> None:
self.dxf.set("value", str(data))
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_dict_var_group_codes, 1)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dict_var.name)
self.dxf.export_dxf_attribs(tagwriter, ["schema", "value"])
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,959 @@
# Copyright (c) 2019-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Optional
from typing_extensions import Self
import logging
from ezdxf.enums import MTextLineAlignment
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
VIRTUAL_TAG,
group_code_mapping,
RETURN_DEFAULT,
)
from ezdxf.lldxf import const
from ezdxf.lldxf.const import DXF12, DXF2007, DXF2000
from ezdxf.lldxf import validator
from ezdxf.render.arrows import ARROWS
from .dxfentity import SubclassProcessor, DXFEntity, base_class
from .layer import acdb_symbol_table_record
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf import xref
__all__ = ["DimStyle"]
logger = logging.getLogger("ezdxf")
acdb_dimstyle = DefSubclass(
"AcDbDimStyleTableRecord",
{
"name": DXFAttr(2, default="Standard", validator=validator.is_valid_table_name),
"flags": DXFAttr(70, default=0),
"dimpost": DXFAttr(3, default=""),
"dimapost": DXFAttr(4, default=""),
# Arrow names are the base data -> handle (DXF2000) is set at export
"dimblk": DXFAttr(5, default=""),
"dimblk1": DXFAttr(6, default=""),
"dimblk2": DXFAttr(7, default=""),
"dimscale": DXFAttr(40, default=1),
# 0 has a special but unknown meaning, handle as 1.0
"dimasz": DXFAttr(41, default=2.5),
"dimexo": DXFAttr(42, default=0.625),
"dimdli": DXFAttr(43, default=3.75),
"dimexe": DXFAttr(44, default=1.25),
"dimrnd": DXFAttr(45, default=0),
"dimdle": DXFAttr(46, default=0), # dimension line extension
"dimtp": DXFAttr(47, default=0),
"dimtm": DXFAttr(48, default=0),
# undocumented: length of extension line if fixed (dimfxlon = 1)
"dimfxl": DXFAttr(49, dxfversion=DXF2007, default=2.5),
# jog angle, Angle of oblique dimension line segment in jogged radius dimension
"dimjogang": DXFAttr(50, dxfversion=DXF2007, default=90, optional=True),
# measurement text height
"dimtxt": DXFAttr(140, default=2.5),
# center marks and center lines; 0 = off, <0 = center line, >0 = center mark
"dimcen": DXFAttr(141, default=2.5),
"dimtsz": DXFAttr(142, default=0),
"dimaltf": DXFAttr(143, default=0.03937007874),
# measurement length factor
"dimlfac": DXFAttr(144, default=1),
# text vertical position if dimtad=0
"dimtvp": DXFAttr(145, default=0),
"dimtfac": DXFAttr(146, default=1),
# default gap around the measurement text
"dimgap": DXFAttr(147, default=0.625),
"dimaltrnd": DXFAttr(148, dxfversion=DXF2000, default=0),
# 0=None, 1=canvas color, 2=dimtfillclr
"dimtfill": DXFAttr(69, dxfversion=DXF2007, default=0),
# color index for dimtfill==2
"dimtfillclr": DXFAttr(70, dxfversion=DXF2007, default=0),
"dimtol": DXFAttr(71, default=0),
"dimlim": DXFAttr(72, default=0),
# text inside horizontal
"dimtih": DXFAttr(73, default=0),
# text outside horizontal
"dimtoh": DXFAttr(74, default=0),
# suppress extension line 1
"dimse1": DXFAttr(75, default=0),
# suppress extension line 2
"dimse2": DXFAttr(76, default=0),
# text vertical location: 0=center; 1+2+3=above; 4=below
"dimtad": DXFAttr(77, default=1),
"dimzin": DXFAttr(78, default=8),
# dimazin:
# 0 = Displays all leading and trailing zeros
# 1 = Suppresses leading zeros in decimal dimensions (for example, 0.5000 becomes .5000)
# 2 = Suppresses trailing zeros in decimal dimensions (for example, 12.5000 becomes 12.5)
# 3 = Suppresses leading and trailing zeros (for example, 0.5000 becomes .5)
"dimazin": DXFAttr(79, default=3, dxfversion=DXF2000),
# dimarcsym: show arc symbol
# 0 = preceding text
# 1 = above text
# 2 = disable
"dimarcsym": DXFAttr(90, dxfversion=DXF2000, optional=True),
"dimalt": DXFAttr(170, default=0),
"dimaltd": DXFAttr(171, default=3),
"dimtofl": DXFAttr(172, default=1),
"dimsah": DXFAttr(173, default=0),
# force dimension text inside
"dimtix": DXFAttr(174, default=0),
"dimsoxd": DXFAttr(175, default=0),
# dimension line color
"dimclrd": DXFAttr(176, default=0),
# extension line color
"dimclre": DXFAttr(177, default=0),
# text color
"dimclrt": DXFAttr(178, default=0),
"dimadec": DXFAttr(179, dxfversion=DXF2000, default=2),
"dimunit": DXFAttr(270), # obsolete
"dimdec": DXFAttr(271, dxfversion=DXF2000, default=2),
# can appear multiple times ???
"dimtdec": DXFAttr(272, dxfversion=DXF2000, default=2),
"dimaltu": DXFAttr(273, dxfversion=DXF2000, default=2),
"dimalttd": DXFAttr(274, dxfversion=DXF2000, default=3),
# 0 = Decimal degrees
# 1 = Degrees/minutes/seconds
# 2 = Grad
# 3 = Radians
"dimaunit": DXFAttr(
275,
dxfversion=DXF2000,
default=0,
validator=validator.is_in_integer_range(0, 4),
fixer=RETURN_DEFAULT,
),
"dimfrac": DXFAttr(276, dxfversion=DXF2000, default=0),
"dimlunit": DXFAttr(277, dxfversion=DXF2000, default=2),
"dimdsep": DXFAttr(278, dxfversion=DXF2000, default=44),
# 0 = Moves the dimension line with dimension text
# 1 = Adds a leader when dimension text is moved
# 2 = Allows text to be moved freely without a leader
"dimtmove": DXFAttr(279, dxfversion=DXF2000, default=0),
# 0=center; 1=left; 2=right; 3=above ext1; 4=above ext2
"dimjust": DXFAttr(280, dxfversion=DXF2000, default=0),
# suppress first part of the dimension line
"dimsd1": DXFAttr(281, dxfversion=DXF2000, default=0),
# suppress second part of the dimension line
"dimsd2": DXFAttr(282, dxfversion=DXF2000, default=0),
"dimtolj": DXFAttr(283, dxfversion=DXF2000, default=0),
"dimtzin": DXFAttr(284, dxfversion=DXF2000, default=8),
"dimaltz": DXFAttr(285, dxfversion=DXF2000, default=0),
"dimalttz": DXFAttr(286, dxfversion=DXF2000, default=0),
"dimfit": DXFAttr(287), # obsolete, now use DIMATFIT and DIMTMOVE
"dimupt": DXFAttr(288, dxfversion=DXF2000, default=0),
# Determines how dimension text and arrows are arranged when space is
# not sufficient to place both within the extension lines.
# 0 = Places both text and arrows outside extension lines
# 1 = Moves arrows first, then text
# 2 = Moves text first, then arrows
# 3 = Moves either text or arrows, whichever fits best
"dimatfit": DXFAttr(289, dxfversion=DXF2000, default=3),
# undocumented: 1 = fixed extension line length
"dimfxlon": DXFAttr(290, dxfversion=DXF2007, default=0),
# Virtual tags are transformed at DXF export - for DIMSTYLE the
# resource names are exported as <name>_handle tags:
# virtual: set/get STYLE by name
"dimtxsty": DXFAttr(VIRTUAL_TAG, dxfversion=DXF2000),
# virtual: set/get leader arrow by block name
"dimldrblk": DXFAttr(VIRTUAL_TAG, dxfversion=DXF2000),
# virtual: set/get LINETYPE by name
"dimltype": DXFAttr(VIRTUAL_TAG, dxfversion=DXF2007),
# virtual: set/get referenced LINETYPE by name
"dimltex2": DXFAttr(VIRTUAL_TAG, dxfversion=DXF2007),
# virtual: set/get referenced LINETYPE by name
"dimltex1": DXFAttr(VIRTUAL_TAG, dxfversion=DXF2007),
# Entity handles are not used internally (see virtual tags above),
# these handles are set at DXF export:
# handle of referenced STYLE entry
"dimtxsty_handle": DXFAttr(340, dxfversion=DXF2000),
# handle of referenced BLOCK_RECORD
"dimblk_handle": DXFAttr(342, dxfversion=DXF2000),
# handle of referenced BLOCK_RECORD
"dimblk1_handle": DXFAttr(343, dxfversion=DXF2000),
# handle of referenced BLOCK_RECORD
"dimblk2_handle": DXFAttr(344, dxfversion=DXF2000),
# handle of referenced BLOCK_RECORD
"dimldrblk_handle": DXFAttr(341, dxfversion=DXF2000),
# handle of linetype for dimension line
"dimltype_handle": DXFAttr(345, dxfversion=DXF2007),
# handle of linetype for extension line 1
"dimltex1_handle": DXFAttr(346, dxfversion=DXF2007),
# handle of linetype for extension line 2
"dimltex2_handle": DXFAttr(347, dxfversion=DXF2007),
# dimension line lineweight enum value, default BYBLOCK
"dimlwd": DXFAttr(371, default=const.LINEWEIGHT_BYBLOCK, dxfversion=DXF2000),
# extension line lineweight enum value, default BYBLOCK
"dimlwe": DXFAttr(372, default=const.LINEWEIGHT_BYBLOCK, dxfversion=DXF2000),
},
)
acdb_dimstyle_group_codes = group_code_mapping(acdb_dimstyle)
EXPORT_MAP_R2007 = [
"name",
"flags",
"dimscale",
"dimasz",
"dimexo",
"dimdli",
"dimexe",
"dimrnd",
"dimdle",
"dimtp",
"dimtm",
"dimfxl",
"dimjogang",
"dimtxt",
"dimcen",
"dimtsz",
"dimaltf",
"dimlfac",
"dimtvp",
"dimtfac",
"dimgap",
"dimaltrnd",
"dimtfill",
"dimtfillclr",
"dimtol",
"dimlim",
"dimtih",
"dimtoh",
"dimse1",
"dimse2",
"dimtad",
"dimzin",
"dimazin",
"dimarcsym",
"dimalt",
"dimaltd",
"dimtofl",
"dimsah",
"dimtix",
"dimsoxd",
"dimclrd",
"dimclre",
"dimclrt",
"dimadec",
"dimdec",
"dimtdec",
"dimaltu",
"dimalttd",
"dimaunit",
"dimfrac",
"dimlunit",
"dimdsep",
"dimtmove",
"dimjust",
"dimsd1",
"dimsd2",
"dimtolj",
"dimtzin",
"dimaltz",
"dimalttz",
"dimupt",
"dimatfit",
"dimfxlon",
"dimtxsty_handle",
"dimldrblk_handle",
"dimblk_handle",
"dimblk1_handle",
"dimblk2_handle",
"dimltype_handle",
"dimltex1_handle",
"dimltex2_handle",
"dimlwd",
"dimlwe",
]
EXPORT_MAP_R2000 = [
"name",
"flags",
"dimpost",
"dimapost",
"dimscale",
"dimasz",
"dimexo",
"dimdli",
"dimexe",
"dimrnd",
"dimdle",
"dimtp",
"dimtm",
"dimtxt",
"dimcen",
"dimtsz",
"dimaltf",
"dimlfac",
"dimtvp",
"dimtfac",
"dimgap",
"dimaltrnd",
"dimtol",
"dimlim",
"dimtih",
"dimtoh",
"dimse1",
"dimse2",
"dimtad",
"dimzin",
"dimazin",
"dimarcsym",
"dimalt",
"dimaltd",
"dimtofl",
"dimsah",
"dimtix",
"dimsoxd",
"dimclrd",
"dimclre",
"dimclrt",
"dimadec",
"dimdec",
"dimtdec",
"dimaltu",
"dimalttd",
"dimaunit",
"dimfrac",
"dimlunit",
"dimdsep",
"dimtmove",
"dimjust",
"dimsd1",
"dimsd2",
"dimtolj",
"dimtzin",
"dimaltz",
"dimalttz",
"dimupt",
"dimatfit",
"dimtxsty_handle",
"dimldrblk_handle",
"dimblk_handle",
"dimblk1_handle",
"dimblk2_handle",
"dimlwd",
"dimlwe",
]
EXPORT_MAP_R12 = [
"name",
"flags",
"dimpost",
"dimapost",
"dimblk",
"dimblk1",
"dimblk2",
"dimscale",
"dimasz",
"dimexo",
"dimdli",
"dimexe",
"dimrnd",
"dimdle",
"dimtp",
"dimtm",
"dimtxt",
"dimcen",
"dimtsz",
"dimaltf",
"dimlfac",
"dimtvp",
"dimtfac",
"dimgap",
"dimtol",
"dimlim",
"dimtih",
"dimtoh",
"dimse1",
"dimse2",
"dimtad",
"dimzin",
"dimalt",
"dimaltd",
"dimtofl",
"dimsah",
"dimtix",
"dimsoxd",
"dimclrd",
"dimclre",
"dimclrt",
]
DIM_TEXT_STYLE_ATTR = "dimtxsty"
DIM_ARROW_HEAD_ATTRIBS = ("dimblk", "dimblk1", "dimblk2", "dimldrblk")
DIM_LINETYPE_ATTRIBS = ("dimltype", "dimltex1", "dimltex2")
def dim_filter(name: str) -> bool:
return name.startswith("dim")
@register_entity
class DimStyle(DXFEntity):
"""DXF BLOCK_RECORD table entity"""
DXFTYPE = "DIMSTYLE"
DXFATTRIBS = DXFAttributes(base_class, acdb_symbol_table_record, acdb_dimstyle)
CODE_TO_DXF_ATTRIB = dict(DXFATTRIBS.build_group_code_items(dim_filter))
@property
def dxfversion(self):
return self.doc.dxfversion
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
# group code 70 is used 2x, simple_dxfattribs_loader() can't be used!
processor.fast_load_dxfattribs(dxf, acdb_dimstyle_group_codes, 2)
return dxf
def post_load_hook(self, doc: Drawing) -> None:
# 2nd Loading stage: resolve handles to names.
# ezdxf uses names for blocks, linetypes and text style as internal
# data, handles are set at export.
super().post_load_hook(doc)
db = doc.entitydb
for attrib_name in DIM_ARROW_HEAD_ATTRIBS:
if self.dxf.hasattr(attrib_name):
continue
block_record_handle = self.dxf.get(attrib_name + "_handle")
if block_record_handle and block_record_handle != "0":
try:
name = db[block_record_handle].dxf.name
except KeyError:
logger.info(
f"Replace undefined block reference "
f"#{block_record_handle} by default arrow."
)
name = "" # default arrow name
else:
name = "" # default arrow name
self.dxf.set(attrib_name, name)
style_handle = self.dxf.get("dimtxsty_handle", None)
if style_handle and style_handle != "0":
try:
self.dxf.dimtxsty = db[style_handle].dxf.name
except (KeyError, AttributeError):
logger.info(f"Ignore undefined text style #{style_handle}.")
for attrib_name in DIM_LINETYPE_ATTRIBS:
lt_handle = self.dxf.get(attrib_name + "_handle", None)
if lt_handle and lt_handle != "0":
try:
name = db[lt_handle].dxf.name
except (KeyError, AttributeError):
logger.info(f"Ignore undefined line type #{lt_handle}.")
else:
self.dxf.set(attrib_name, name)
# Remove all handles, to be sure setting handles for resource names
# at export.
self.discard_handles()
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_symbol_table_record.name)
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_dimstyle.name)
if tagwriter.dxfversion > DXF12:
# Set handles from dimblk names:
self.set_handles()
if tagwriter.dxfversion == DXF12:
attribs = EXPORT_MAP_R12
elif tagwriter.dxfversion < DXF2007:
attribs = EXPORT_MAP_R2000
else:
attribs = EXPORT_MAP_R2007
self.dxf.export_dxf_attribs(tagwriter, attribs)
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
assert self.doc is not None, "DIMSTYLE entity must be assigned to a document"
super().register_resources(registry)
# ezdxf uses names for blocks, linetypes and text style as internal data
# register text style
text_style_name = self.dxf.get(DIM_TEXT_STYLE_ATTR)
if text_style_name:
try:
style = self.doc.styles.get(text_style_name)
registry.add_entity(style)
except const.DXFTableEntryError:
pass
# register linetypes
for attr_name in DIM_LINETYPE_ATTRIBS:
ltype_name = self.dxf.get(attr_name)
if ltype_name is None:
continue
try:
ltype = self.doc.linetypes.get(ltype_name)
registry.add_entity(ltype)
except const.DXFTableEntryError:
pass
# Note: ACAD arrow head blocks are created automatically at export in set_blk_handle()
for attr_name in DIM_ARROW_HEAD_ATTRIBS:
arrow_name = self.dxf.get(attr_name)
if arrow_name is None:
continue
if not ARROWS.is_acad_arrow(arrow_name):
# user defined arrow head block
registry.add_block_name(arrow_name)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
assert isinstance(clone, DimStyle)
super().map_resources(clone, mapping)
# ezdxf uses names for blocks, linetypes and text style as internal data
# map text style
text_style = self.dxf.get(DIM_TEXT_STYLE_ATTR)
if text_style:
clone.dxf.dimtxsty = mapping.get_text_style(text_style)
# map linetypes
for attr_name in DIM_LINETYPE_ATTRIBS:
ltype_name = self.dxf.get(attr_name)
if ltype_name:
clone.dxf.set(attr_name, mapping.get_linetype(ltype_name))
# Note: ACAD arrow head blocks are created automatically at export in set_blk_handle()
for attr_name in DIM_ARROW_HEAD_ATTRIBS:
arrow_name = self.dxf.get(attr_name)
if arrow_name is None:
continue
if not ARROWS.is_acad_arrow(arrow_name):
# user defined arrow head block
arrow_name = mapping.get_block_name(arrow_name)
clone.dxf.set(attr_name, arrow_name)
def set_handles(self):
style = self.dxf.get(DIM_TEXT_STYLE_ATTR)
if style:
self.dxf.dimtxsty_handle = self.doc.styles.get(style).dxf.handle
for attr_name in DIM_ARROW_HEAD_ATTRIBS:
block_name = self.dxf.get(attr_name)
if block_name:
self.set_blk_handle(attr_name + "_handle", block_name)
for attr_name in DIM_LINETYPE_ATTRIBS:
get_linetype = self.doc.linetypes.get
ltype_name = self.dxf.get(attr_name)
if ltype_name:
handle = get_linetype(ltype_name).dxf.handle
self.dxf.set(attr_name + "_handle", handle)
def discard_handles(self):
for attr in (
"dimblk",
"dimblk1",
"dimblk2",
"dimldrblk",
"dimltype",
"dimltex1",
"dimltex2",
"dimtxsty",
):
self.dxf.discard(attr + "_handle")
def set_blk_handle(self, attr: str, arrow_name: str) -> None:
if arrow_name == ARROWS.closed_filled:
# special arrow, no handle needed (is '0' if set)
# do not create block by default, this will be done if arrow is used
# and block record handle is not needed here
self.dxf.discard(attr)
return
assert self.doc is not None, "valid DXF document required"
blocks = self.doc.blocks
if ARROWS.is_acad_arrow(arrow_name):
# create block, because the block record handle is needed here
block_name = ARROWS.create_block(blocks, arrow_name)
else:
block_name = arrow_name
blk = blocks.get(block_name)
if blk is not None:
self.set_dxf_attrib(attr, blk.block_record_handle)
else:
raise const.DXFValueError(f'Block "{arrow_name}" does not exist.')
def get_arrow_block_name(self, name: str) -> str:
assert self.doc is not None, "valid DXF document required"
handle = self.get_dxf_attrib(name, None)
if handle in (None, "0"):
# unset handle or handle '0' is default closed filled arrow
return ARROWS.closed_filled
else:
block_name = get_block_name_by_handle(handle, self.doc)
# Returns standard arrow name or the user defined block name:
return ARROWS.arrow_name(block_name)
def set_linetypes(self, dimline=None, ext1=None, ext2=None) -> None:
if self.dxfversion < DXF2007:
logger.debug("Linetype support requires DXF R2007+.")
if dimline is not None:
self.dxf.dimltype = dimline
if ext1 is not None:
self.dxf.dimltex1 = ext1
if ext2 is not None:
self.dxf.dimltex2 = ext2
def print_dim_attribs(self) -> None:
attdef = self.DXFATTRIBS.get
for name, value in self.dxfattribs().items():
if name.startswith("dim"):
print(f"{name} ({attdef(name).code}) = {value}") # type: ignore
def copy_to_header(self, doc: Drawing):
"""Copy all dimension style variables to HEADER section of `doc`."""
attribs = self.dxfattribs()
header = doc.header
header["$DIMSTYLE"] = self.dxf.name
for name, value in attribs.items():
if name.startswith("dim"):
header_var = "$" + name.upper()
try:
header[header_var] = value
except const.DXFKeyError:
logger.debug(f"Unsupported header variable: {header_var}.")
def set_arrows(
self, blk: str = "", blk1: str = "", blk2: str = "", ldrblk: str = ""
) -> None:
"""Set arrows by block names or AutoCAD standard arrow names, set
DIMTSZ to ``0`` which disables tick.
Args:
blk: block/arrow name for both arrows, if DIMSAH is 0
blk1: block/arrow name for first arrow, if DIMSAH is 1
blk2: block/arrow name for second arrow, if DIMSAH is 1
ldrblk: block/arrow name for leader
"""
self.set_dxf_attrib("dimblk", blk)
self.set_dxf_attrib("dimblk1", blk1)
self.set_dxf_attrib("dimblk2", blk2)
self.set_dxf_attrib("dimldrblk", ldrblk)
self.set_dxf_attrib("dimtsz", 0) # use blocks
# only existing BLOCK definitions allowed
if self.doc:
blocks = self.doc.blocks
for b in (blk, blk1, blk2, ldrblk):
if ARROWS.is_acad_arrow(b): # not real blocks
ARROWS.create_block(blocks, b)
continue
if b and b not in blocks:
raise const.DXFValueError(f'BLOCK "{blk}" does not exist.')
def set_tick(self, size: float = 1) -> None:
"""Set tick `size`, which also disables arrows, a tick is just an
oblique stroke as marker.
Args:
size: arrow size in drawing units
"""
self.set_dxf_attrib("dimtsz", size)
def set_text_align(
self,
halign: Optional[str] = None,
valign: Optional[str] = None,
vshift: Optional[float] = None,
) -> None:
"""Set measurement text alignment, `halign` defines the horizontal
alignment (requires DXF R2000+), `valign` defines the vertical
alignment, `above1` and `above2` means above extension line 1 or 2 and
aligned with extension line.
Args:
halign: "left", "right", "center", "above1", "above2",
requires DXF R2000+
valign: "above", "center", "below"
vshift: vertical text shift, if `valign` is "center";
>0 shift upward,
<0 shift downwards
"""
if valign:
valign = valign.lower()
self.set_dxf_attrib("dimtad", const.DIMTAD[valign])
if valign == "center" and vshift is not None:
self.set_dxf_attrib("dimtvp", vshift)
if halign:
self.set_dxf_attrib("dimjust", const.DIMJUST[halign.lower()])
def set_text_format(
self,
prefix: str = "",
postfix: str = "",
rnd: Optional[float] = None,
dec: Optional[int] = None,
sep: Optional[str] = None,
leading_zeros: bool = True,
trailing_zeros: bool = True,
):
"""Set dimension text format, like prefix and postfix string, rounding
rule and number of decimal places.
Args:
prefix: Dimension text prefix text as string
postfix: Dimension text postfix text as string
rnd: Rounds all dimensioning distances to the specified value, for
instance, if DIMRND is set to 0.25, all distances round to the
nearest 0.25 unit. If you set DIMRND to 1.0, all distances round
to the nearest integer.
dec: Sets the number of decimal places displayed for the primary
units of a dimension, requires DXF R2000+
sep: "." or "," as decimal separator, requires DXF R2000+
leading_zeros: Suppress leading zeros for decimal dimensions
if ``False``
trailing_zeros: Suppress trailing zeros for decimal dimensions
if ``False``
"""
if prefix or postfix:
self.dxf.dimpost = prefix + "<>" + postfix
if rnd is not None:
self.dxf.dimrnd = rnd
# works only with decimal dimensions not inch and feet, US user set dimzin directly
if leading_zeros is not None or trailing_zeros is not None:
dimzin = 0
if leading_zeros is False:
dimzin = const.DIMZIN_SUPPRESSES_LEADING_ZEROS
if trailing_zeros is False:
dimzin += const.DIMZIN_SUPPRESSES_TRAILING_ZEROS
self.dxf.dimzin = dimzin
if dec is not None:
self.dxf.dimdec = dec
if sep is not None:
self.dxf.dimdsep = ord(sep)
def set_dimline_format(
self,
color: Optional[int] = None,
linetype: Optional[str] = None,
lineweight: Optional[int] = None,
extension: Optional[float] = None,
disable1: Optional[bool] = None,
disable2: Optional[bool] = None,
):
"""Set dimension line properties
Args:
color: color index
linetype: linetype as string, requires DXF R2007+
lineweight: line weight as int, 13 = 0.13mm, 200 = 2.00mm,
requires DXF R2000+
extension: extension length
disable1: ``True`` to suppress first part of dimension line,
requires DXF R2000+
disable2: ``True`` to suppress second part of dimension line,
requires DXF R2000+
"""
if color is not None:
self.dxf.dimclrd = color
if extension is not None:
self.dxf.dimdle = extension
if lineweight is not None:
self.dxf.dimlwd = lineweight
if disable1 is not None:
self.dxf.dimsd1 = disable1
if disable2 is not None:
self.dxf.dimsd2 = disable2
if linetype is not None:
self.dxf.dimltype = linetype
def set_extline_format(
self,
color: Optional[int] = None,
lineweight: Optional[int] = None,
extension: Optional[float] = None,
offset: Optional[float] = None,
fixed_length: Optional[float] = None,
):
"""Set common extension line attributes.
Args:
color: color index
lineweight: line weight as int, 13 = 0.13mm, 200 = 2.00mm
extension: extension length above dimension line
offset: offset from measurement point
fixed_length: set fixed length extension line, length below the
dimension line
"""
if color is not None:
self.dxf.dimclre = color
if extension is not None:
self.dxf.dimexe = extension
if offset is not None:
self.dxf.dimexo = offset
if lineweight is not None:
self.dxf.dimlwe = lineweight
if fixed_length is not None:
self.dxf.dimfxlon = 1
self.dxf.dimfxl = fixed_length
def set_extline1(self, linetype: Optional[str] = None, disable=False):
"""Set extension line 1 attributes.
Args:
linetype: linetype for extension line 1, requires DXF R2007+
disable: disable extension line 1 if ``True``
"""
if disable:
self.dxf.dimse1 = 1
if linetype is not None:
self.dxf.dimltex1 = linetype
def set_extline2(self, linetype: Optional[str] = None, disable=False):
"""Set extension line 2 attributes.
Args:
linetype: linetype for extension line 2, requires DXF R2007+
disable: disable extension line 2 if ``True``
"""
if disable:
self.dxf.dimse2 = 1
if linetype is not None:
self.dxf.dimltex2 = linetype
def set_tolerance(
self,
upper: float,
lower: Optional[float] = None,
hfactor: float = 1.0,
align: Optional[MTextLineAlignment] = None,
dec: Optional[int] = None,
leading_zeros: Optional[bool] = None,
trailing_zeros: Optional[bool] = None,
) -> None:
"""Set tolerance text format, upper and lower value, text height
factor, number of decimal places or leading and trailing zero
suppression.
Args:
upper: upper tolerance value
lower: lower tolerance value, if ``None`` same as upper
hfactor: tolerance text height factor in relation to the dimension
text height
align: tolerance text alignment enum :class:`ezdxf.enums.MTextLineAlignment`
requires DXF R2000+
dec: Sets the number of decimal places displayed,
requires DXF R2000+
leading_zeros: suppress leading zeros for decimal dimensions
if ``False``, requires DXF R2000+
trailing_zeros: suppress trailing zeros for decimal dimensions
if ``False``, requires DXF R2000+
"""
# Exclusive tolerances mode, disable limits
self.dxf.dimtol = 1
self.dxf.dimlim = 0
self.dxf.dimtp = float(upper)
if lower is not None:
self.dxf.dimtm = float(lower)
else:
self.dxf.dimtm = float(upper)
if hfactor is not None:
self.dxf.dimtfac = float(hfactor)
# Works only with decimal dimensions not inch and feet, US user set
# dimzin directly.
if leading_zeros is not None or trailing_zeros is not None:
dimtzin = 0
if leading_zeros is False:
dimtzin = const.DIMZIN_SUPPRESSES_LEADING_ZEROS
if trailing_zeros is False:
dimtzin += const.DIMZIN_SUPPRESSES_TRAILING_ZEROS
self.dxf.dimtzin = dimtzin
if align is not None:
self.dxf.dimtolj = int()
if dec is not None:
self.dxf.dimtdec = int(dec)
def set_limits(
self,
upper: float,
lower: float,
hfactor: float = 1.0,
dec: Optional[int] = None,
leading_zeros: Optional[bool] = None,
trailing_zeros: Optional[bool] = None,
) -> None:
"""Set limits text format, upper and lower limit values, text height
factor, number of decimal places or leading and trailing zero
suppression.
Args:
upper: upper limit value added to measurement value
lower: lower limit value subtracted from measurement value
hfactor: limit text height factor in relation to the dimension
text height
dec: Sets the number of decimal places displayed,
requires DXF R2000+
leading_zeros: suppress leading zeros for decimal dimensions
if ``False``, requires DXF R2000+
trailing_zeros: suppress trailing zeros for decimal dimensions
if ``False``, requires DXF R2000+
"""
# Exclusive limits mode, disable tolerances
self.dxf.dimlim = 1
self.dxf.dimtol = 0
self.dxf.dimtp = float(upper)
self.dxf.dimtm = float(lower)
self.dxf.dimtfac = float(hfactor)
# Works only with decimal dimensions not inch and feet, US user set
# dimzin directly.
if leading_zeros is not None or trailing_zeros is not None:
dimtzin = 0
if leading_zeros is False:
dimtzin = const.DIMZIN_SUPPRESSES_LEADING_ZEROS
if trailing_zeros is False:
dimtzin += const.DIMZIN_SUPPRESSES_TRAILING_ZEROS
self.dxf.dimtzin = dimtzin
self.dxf.dimtolj = 0 # set bottom as default
if dec is not None:
self.dxf.dimtdec = int(dec)
def __referenced_blocks__(self) -> Iterable[str]:
"""Support for "ReferencedBlocks" protocol."""
if self.doc:
blocks = self.doc.blocks
for attrib_name in ("dimblk", "dimblk1", "dimblk2", "dimldrblk"):
name = self.dxf.get(attrib_name, None)
if name:
block = blocks.get(name, None)
if block is not None:
yield block.block_record.dxf.handle
def get_block_name_by_handle(handle, doc: Drawing, default="") -> str:
try:
entry = doc.entitydb[handle]
except const.DXFKeyError:
block_name = default
else:
block_name = entry.dxf.name
return block_name
@@ -0,0 +1,583 @@
# Copyright (c) 2019-2023 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Any, TYPE_CHECKING, Optional
from typing_extensions import Protocol
import logging
from ezdxf.enums import MTextLineAlignment
from ezdxf.lldxf import const
from ezdxf.lldxf.const import DXFAttributeError, DIMJUST, DIMTAD
from ezdxf.math import Vec3, UVec, UCS
from ezdxf.render.arrows import ARROWS
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import DimStyle, Dimension
from ezdxf.render.dim_base import BaseDimensionRenderer
from ezdxf import xref
logger = logging.getLogger("ezdxf")
class SupportsOverride(Protocol):
def override(self) -> DimStyleOverride:
...
class DimStyleOverride:
def __init__(self, dimension: Dimension, override: Optional[dict] = None):
self.dimension = dimension
dim_style_name: str = dimension.get_dxf_attrib("dimstyle", "STANDARD")
self.dimstyle: DimStyle = self.doc.dimstyles.get(dim_style_name)
self.dimstyle_attribs: dict = self.get_dstyle_dict()
# Special ezdxf attributes beyond the DXF reference, therefore not
# stored in the DSTYLE data.
# These are only rendering effects or data transfer objects
# user_location: Vec3 - user location override if not None
# relative_user_location: bool - user location override relative to
# dimline center if True
# text_shift_h: float - shift text in text direction, relative to
# standard text location
# text_shift_v: float - shift text perpendicular to text direction,
# relative to standard text location
self.update(override or {})
@property
def doc(self) -> Drawing:
"""Drawing object (internal API)"""
return self.dimension.doc # type: ignore
@property
def dxfversion(self) -> str:
"""DXF version (internal API)"""
return self.doc.dxfversion
def get_dstyle_dict(self) -> dict:
"""Get XDATA section ACAD:DSTYLE, to override DIMSTYLE attributes for
this DIMENSION entity.
Returns a ``dict`` with DIMSTYLE attribute names as keys.
(internal API)
"""
return self.dimension.get_acad_dstyle(self.dimstyle)
def get(self, attribute: str, default: Any = None) -> Any:
"""Returns DIMSTYLE `attribute` from override dict
:attr:`dimstyle_attribs` or base :class:`DimStyle`.
Returns `default` value for attributes not supported by DXF R12. This
is a hack to use the same algorithm to render DXF R2000 and DXF R12
DIMENSION entities. But the DXF R2000 attributes are not stored in the
DXF R12 file! This method does not catch invalid attribute names!
Check debug log for ignored DIMSTYLE attributes.
"""
if attribute in self.dimstyle_attribs:
result = self.dimstyle_attribs[attribute]
else:
try:
result = self.dimstyle.get_dxf_attrib(attribute, default)
except DXFAttributeError:
result = default
return result
def pop(self, attribute: str, default: Any = None) -> Any:
"""Returns DIMSTYLE `attribute` from override dict :attr:`dimstyle_attribs` and
removes this `attribute` from override dict.
"""
value = self.get(attribute, default)
# delete just from override dict
del self[attribute]
return value
def update(self, attribs: dict) -> None:
"""Update override dict :attr:`dimstyle_attribs`.
Args:
attribs: ``dict`` of DIMSTYLE attributes
"""
self.dimstyle_attribs.update(attribs)
def __getitem__(self, key: str) -> Any:
"""Returns DIMSTYLE attribute `key`, see also :meth:`get`."""
return self.get(key)
def __setitem__(self, key: str, value: Any) -> None:
"""Set DIMSTYLE attribute `key` in :attr:`dimstyle_attribs`."""
self.dimstyle_attribs[key] = value
def __delitem__(self, key: str) -> None:
"""Deletes DIMSTYLE attribute `key` from :attr:`dimstyle_attribs`,
ignores :class:`KeyErrors` silently.
"""
try:
del self.dimstyle_attribs[key]
except KeyError: # silent discard
pass
def register_resources_r12(self, registry: xref.Registry) -> None:
# DXF R2000+ references overridden resources by group code 1005 handles in the
# XDATA section, which are automatically mapped by the parent class DXFEntity!
assert self.doc.dxfversion == const.DXF12
# register arrow heads
for attrib_name in ("dimblk", "dimblk1", "dimblk2", "dimldrblk"):
arrow_name = self.get(attrib_name, "")
if arrow_name:
# arrow head names will be renamed like user blocks
# e.g. "_DOT" -> "xref$0$_DOT"
registry.add_block_name(ARROWS.block_name(arrow_name))
# linetype and text style attributes are not supported by DXF R12!
def map_resources_r12(
self, copy: SupportsOverride, mapping: xref.ResourceMapper
) -> None:
# DXF R2000+ references overridden resources by group code 1005 handles in the
# XDATA section, which are automatically mapped by the parent class DXFEntity!
assert self.doc.dxfversion == const.DXF12
copy_override = copy.override()
# map arrow heads
for attrib_name in ("dimblk", "dimblk1", "dimblk2", "dimldrblk"):
arrow_name = self.get(attrib_name, "")
if arrow_name:
block_name = mapping.get_block_name(ARROWS.block_name(arrow_name))
copy_override[attrib_name] = ARROWS.arrow_name(block_name)
copy_override.commit()
# The linetype attributes dimltype, dimltex1 and dimltex2 and the text style
# attribute dimtxsty are not supported by DXF R12!
#
# Weired behavior for DXF R12 detected
# ------------------------------------
# BricsCAD writes the handles of overridden linetype- and text style attributes
# into the ACAD-DSTYLE dictionary like for DXF R2000+, but exports the table
# entries without handles, so remapping of these handles is only possible if the
# application (which loads this DXF file) assigns internally the same handles
# as BricsCAD does and this also works with Autodesk TrueView (oO = wtf!).
# Ezdxf cannot remap these handles!
def commit(self) -> None:
"""Writes overridden DIMSTYLE attributes into ACAD:DSTYLE section of
XDATA of the DIMENSION entity.
"""
self.dimension.set_acad_dstyle(self.dimstyle_attribs)
def set_arrows(
self,
blk: Optional[str] = None,
blk1: Optional[str] = None,
blk2: Optional[str] = None,
ldrblk: Optional[str] = None,
size: Optional[float] = None,
) -> None:
"""Set arrows or user defined blocks and disable oblique stroke as tick.
Args:
blk: defines both arrows at once as name str or user defined block
blk1: defines left arrow as name str or as user defined block
blk2: defines right arrow as name str or as user defined block
ldrblk: defines leader arrow as name str or as user defined block
size: arrow size in drawing units
"""
def set_arrow(dimvar: str, name: str) -> None:
self.dimstyle_attribs[dimvar] = name
if size is not None:
self.dimstyle_attribs["dimasz"] = float(size)
if blk is not None:
set_arrow("dimblk", blk)
self.dimstyle_attribs["dimsah"] = 0
self.dimstyle_attribs["dimtsz"] = 0.0 # use arrows
if blk1 is not None:
set_arrow("dimblk1", blk1)
self.dimstyle_attribs["dimsah"] = 1
self.dimstyle_attribs["dimtsz"] = 0.0 # use arrows
if blk2 is not None:
set_arrow("dimblk2", blk2)
self.dimstyle_attribs["dimsah"] = 1
self.dimstyle_attribs["dimtsz"] = 0.0 # use arrows
if ldrblk is not None:
set_arrow("dimldrblk", ldrblk)
def get_arrow_names(self) -> tuple[str, str]:
"""Get arrow names as strings like 'ARCHTICK' as tuple (dimblk1, dimblk2)."""
dimtsz = self.get("dimtsz", 0)
blk1, blk2 = "", ""
if dimtsz == 0.0:
if bool(self.get("dimsah")):
blk1 = self.get("dimblk1", "")
blk2 = self.get("dimblk2", "")
else:
blk = self.get("dimblk", "")
blk1 = blk
blk2 = blk
return blk1, blk2
def get_decimal_separator(self) -> str:
dimdsep: int = self.get("dimdsep", 0)
return "," if dimdsep == 0 else chr(dimdsep)
def set_tick(self, size: float = 1) -> None:
"""Use oblique stroke as tick, disables arrows.
Args:
size: arrow size in daring units
"""
self.dimstyle_attribs["dimtsz"] = float(size)
def set_text_align(
self,
halign: Optional[str] = None,
valign: Optional[str] = None,
vshift: Optional[float] = None,
) -> None:
"""Set measurement text alignment, `halign` defines the horizontal
alignment, `valign` defines the vertical alignment, `above1` and
`above2` means above extension line 1 or 2 and aligned with extension
line.
Args:
halign: `left`, `right`, `center`, `above1`, `above2`,
requires DXF R2000+
valign: `above`, `center`, `below`
vshift: vertical text shift, if `valign` is `center`;
>0 shift upward, <0 shift downwards
"""
if halign:
self.dimstyle_attribs["dimjust"] = DIMJUST[halign.lower()]
if valign:
valign = valign.lower()
self.dimstyle_attribs["dimtad"] = DIMTAD[valign]
if valign == "center" and vshift is not None:
self.dimstyle_attribs["dimtvp"] = float(vshift)
def set_tolerance(
self,
upper: float,
lower: Optional[float] = None,
hfactor: Optional[float] = None,
align: Optional[MTextLineAlignment] = None,
dec: Optional[int] = None,
leading_zeros: Optional[bool] = None,
trailing_zeros: Optional[bool] = None,
) -> None:
"""Set tolerance text format, upper and lower value, text height
factor, number of decimal places or leading and trailing zero
suppression.
Args:
upper: upper tolerance value
lower: lower tolerance value, if None same as upper
hfactor: tolerance text height factor in relation to the dimension
text height
align: tolerance text alignment enum :class:`ezdxf.enums.MTextLineAlignment`
dec: Sets the number of decimal places displayed
leading_zeros: suppress leading zeros for decimal dimensions if ``False``
trailing_zeros: suppress trailing zeros for decimal dimensions if ``False``
"""
self.dimstyle_attribs["dimtol"] = 1
self.dimstyle_attribs["dimlim"] = 0
self.dimstyle_attribs["dimtp"] = float(upper)
if lower is not None:
self.dimstyle_attribs["dimtm"] = float(lower)
else:
self.dimstyle_attribs["dimtm"] = float(upper)
if hfactor is not None:
self.dimstyle_attribs["dimtfac"] = float(hfactor)
if align is not None:
self.dimstyle_attribs["dimtolj"] = int(align)
if dec is not None:
self.dimstyle_attribs["dimtdec"] = dec
# Works only with decimal dimensions not inch and feet, US user set
# dimzin directly
if leading_zeros is not None or trailing_zeros is not None:
dimtzin = 0
if leading_zeros is False:
dimtzin = const.DIMZIN_SUPPRESSES_LEADING_ZEROS
if trailing_zeros is False:
dimtzin += const.DIMZIN_SUPPRESSES_TRAILING_ZEROS
self.dimstyle_attribs["dimtzin"] = dimtzin
def set_limits(
self,
upper: float,
lower: float,
hfactor: Optional[float] = None,
dec: Optional[int] = None,
leading_zeros: Optional[bool] = None,
trailing_zeros: Optional[bool] = None,
) -> None:
"""Set limits text format, upper and lower limit values, text
height factor, number of decimal places or leading and trailing zero
suppression.
Args:
upper: upper limit value added to measurement value
lower: lower limit value subtracted from measurement value
hfactor: limit text height factor in relation to the dimension
text height
dec: Sets the number of decimal places displayed,
requires DXF R2000+
leading_zeros: suppress leading zeros for decimal dimensions if
``False``, requires DXF R2000+
trailing_zeros: suppress trailing zeros for decimal dimensions if
``False``, requires DXF R2000+
"""
# exclusive limits
self.dimstyle_attribs["dimlim"] = 1
self.dimstyle_attribs["dimtol"] = 0
self.dimstyle_attribs["dimtp"] = float(upper)
self.dimstyle_attribs["dimtm"] = float(lower)
if hfactor is not None:
self.dimstyle_attribs["dimtfac"] = float(hfactor)
# Works only with decimal dimensions not inch and feet, US user set
# dimzin directly.
if leading_zeros is not None or trailing_zeros is not None:
dimtzin = 0
if leading_zeros is False:
dimtzin = const.DIMZIN_SUPPRESSES_LEADING_ZEROS
if trailing_zeros is False:
dimtzin += const.DIMZIN_SUPPRESSES_TRAILING_ZEROS
self.dimstyle_attribs["dimtzin"] = dimtzin
if dec is not None:
self.dimstyle_attribs["dimtdec"] = int(dec)
def set_text_format(
self,
prefix: str = "",
postfix: str = "",
rnd: Optional[float] = None,
dec: Optional[int] = None,
sep: Optional[str] = None,
leading_zeros: Optional[bool] = None,
trailing_zeros: Optional[bool] = None,
) -> None:
"""Set dimension text format, like prefix and postfix string, rounding
rule and number of decimal places.
Args:
prefix: dimension text prefix text as string
postfix: dimension text postfix text as string
rnd: Rounds all dimensioning distances to the specified value, for
instance, if DIMRND is set to 0.25, all distances round to the
nearest 0.25 unit. If you set DIMRND to 1.0, all distances round
to the nearest integer.
dec: Sets the number of decimal places displayed for the primary
units of a dimension. requires DXF R2000+
sep: "." or "," as decimal separator
leading_zeros: suppress leading zeros for decimal dimensions if ``False``
trailing_zeros: suppress trailing zeros for decimal dimensions if ``False``
"""
if prefix or postfix:
self.dimstyle_attribs["dimpost"] = prefix + "<>" + postfix
if rnd is not None:
self.dimstyle_attribs["dimrnd"] = rnd
if dec is not None:
self.dimstyle_attribs["dimdec"] = dec
if sep is not None:
self.dimstyle_attribs["dimdsep"] = ord(sep)
# Works only with decimal dimensions not inch and feet, US user set
# dimzin directly.
if leading_zeros is not None or trailing_zeros is not None:
dimzin = 0
if leading_zeros is False:
dimzin = const.DIMZIN_SUPPRESSES_LEADING_ZEROS
if trailing_zeros is False:
dimzin += const.DIMZIN_SUPPRESSES_TRAILING_ZEROS
self.dimstyle_attribs["dimzin"] = dimzin
def set_dimline_format(
self,
color: Optional[int] = None,
linetype: Optional[str] = None,
lineweight: Optional[int] = None,
extension: Optional[float] = None,
disable1: Optional[bool] = None,
disable2: Optional[bool] = None,
):
"""Set dimension line properties.
Args:
color: color index
linetype: linetype as string
lineweight: line weight as int, 13 = 0.13mm, 200 = 2.00mm
extension: extension length
disable1: True to suppress first part of dimension line
disable2: True to suppress second part of dimension line
"""
if color is not None:
self.dimstyle_attribs["dimclrd"] = color
if linetype is not None:
self.dimstyle_attribs["dimltype"] = linetype
if lineweight is not None:
self.dimstyle_attribs["dimlwd"] = lineweight
if extension is not None:
self.dimstyle_attribs["dimdle"] = extension
if disable1 is not None:
self.dimstyle_attribs["dimsd1"] = disable1
if disable2 is not None:
self.dimstyle_attribs["dimsd2"] = disable2
def set_extline_format(
self,
color: Optional[int] = None,
lineweight: Optional[int] = None,
extension: Optional[float] = None,
offset: Optional[float] = None,
fixed_length: Optional[float] = None,
):
"""Set common extension line attributes.
Args:
color: color index
lineweight: line weight as int, 13 = 0.13mm, 200 = 2.00mm
extension: extension length above dimension line
offset: offset from measurement point
fixed_length: set fixed length extension line, length below the
dimension line
"""
if color is not None:
self.dimstyle_attribs["dimclre"] = color
if lineweight is not None:
self.dimstyle_attribs["dimlwe"] = lineweight
if extension is not None:
self.dimstyle_attribs["dimexe"] = extension
if offset is not None:
self.dimstyle_attribs["dimexo"] = offset
if fixed_length is not None:
self.dimstyle_attribs["dimfxlon"] = 1
self.dimstyle_attribs["dimfxl"] = fixed_length
def set_extline1(self, linetype: Optional[str] = None, disable=False):
"""Set attributes of the first extension line.
Args:
linetype: linetype for the first extension line
disable: disable first extension line if ``True``
"""
if linetype is not None:
self.dimstyle_attribs["dimltex1"] = linetype
if disable:
self.dimstyle_attribs["dimse1"] = 1
def set_extline2(self, linetype: Optional[str] = None, disable=False):
"""Set attributes of the second extension line.
Args:
linetype: linetype for the second extension line
disable: disable the second extension line if ``True``
"""
if linetype is not None:
self.dimstyle_attribs["dimltex2"] = linetype
if disable:
self.dimstyle_attribs["dimse2"] = 1
def set_text(self, text: str = "<>") -> None:
"""Set dimension text.
- `text` = " " to suppress dimension text
- `text` = "" or "<>" to use measured distance as dimension text
- otherwise display `text` literally
"""
self.dimension.dxf.text = text
def shift_text(self, dh: float, dv: float) -> None:
"""Set relative text movement, implemented as user location override
without leader.
Args:
dh: shift text in text direction
dv: shift text perpendicular to text direction
"""
self.dimstyle_attribs["text_shift_h"] = dh
self.dimstyle_attribs["text_shift_v"] = dv
def set_location(self, location: UVec, leader=False, relative=False) -> None:
"""Set text location by user, special version for linear dimensions,
behaves for other dimension types like :meth:`user_location_override`.
Args:
location: user defined text location
leader: create leader from text to dimension line
relative: `location` is relative to default location.
"""
self.user_location_override(location)
linear = self.dimension.dimtype < 2
curved = self.dimension.dimtype in (2, 5, 8)
if linear or curved:
self.dimstyle_attribs["dimtmove"] = 1 if leader else 2
self.dimstyle_attribs["relative_user_location"] = relative
def user_location_override(self, location: UVec) -> None:
"""Set text location by user, `location` is relative to the origin of
the UCS defined in the :meth:`render` method or WCS if the `ucs`
argument is ``None``.
"""
self.dimension.set_flag_state(
self.dimension.USER_LOCATION_OVERRIDE, state=True, name="dimtype"
)
self.dimstyle_attribs["user_location"] = Vec3(location)
def get_renderer(self, ucs: Optional[UCS] = None):
"""Get designated DIMENSION renderer. (internal API)"""
return self.doc.dimension_renderer.dispatch(self, ucs)
def render(self, ucs: Optional[UCS] = None, discard=False) -> BaseDimensionRenderer:
"""Starts the dimension line rendering process and also writes overridden
dimension style attributes into the DSTYLE XDATA section. The rendering process
"draws" the graphical representation of the DIMENSION entity as DXF primitives
(TEXT, LINE, ARC, ...) into an anonymous content BLOCK.
You can discard the content BLOCK for a friendly CAD applications like BricsCAD,
because the rendering of the dimension entity is done automatically by BricsCAD
if the content BLOCK is missing, and the result is in most cases better than the
rendering done by `ezdxf`.
AutoCAD does not render DIMENSION entities automatically, therefore I see
AutoCAD as an unfriendly CAD application.
Args:
ucs: user coordinate system
discard: discard the content BLOCK created by `ezdxf`, this works for
BricsCAD, AutoCAD refuses to open DXF files containing DIMENSION
entities without a content BLOCK
Returns:
The rendering object of the DIMENSION entity for analytics
"""
renderer = self.get_renderer(ucs)
if discard:
self.doc.add_acad_incompatibility_message(
"DIMENSION entity without geometry BLOCK (discard=True)"
)
else:
block = self.doc.blocks.new_anonymous_block(type_char="D")
self.dimension.dxf.geometry = block.name
renderer.render(block)
renderer.finalize()
if len(self.dimstyle_attribs):
self.commit()
return renderer
@@ -0,0 +1,123 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from .dxfns import SubclassProcessor, DXFNamespace
from .dxfentity import DXFEntity
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
group_code_mapping,
)
from ezdxf.lldxf.const import DXF2004, DXF2000
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.lldxf.extendedtags import ExtendedTags
__all__ = ["DXFClass"]
class_def = DefSubclass(
None,
{
# Class DXF record name; always unique
"name": DXFAttr(1),
# C++ class name. Used to bind with software that defines object class
# behavior; always unique
"cpp_class_name": DXFAttr(2),
# Application name. Posted in Alert box when a class definition listed in
# this section is not currently loaded
"app_name": DXFAttr(3),
# Proxy capabilities flag. Bit-coded value that indicates the capabilities
# of this object as a proxy:
# 0 = No operations allowed (0)
# 1 = Erase allowed (0x1)
# 2 = Transform allowed (0x2)
# 4 = Color change allowed (0x4)
# 8 = Layer change allowed (0x8)
# 16 = Linetype change allowed (0x10)
# 32 = Linetype scale change allowed (0x20)
# 64 = Visibility change allowed (0x40)
# 128 = Cloning allowed (0x80)
# 256 = Lineweight change allowed (0x100)
# 512 = Plot Style Name change allowed (0x200)
# 895 = All operations except cloning allowed (0x37F)
# 1023 = All operations allowed (0x3FF)
# 1024 = Disables proxy warning dialog (0x400)
# 32768 = R13 format proxy (0x8000)
"flags": DXFAttr(90, default=0),
# Instance count for a custom class
"instance_count": DXFAttr(91, dxfversion=DXF2004, default=0),
# Was-a-proxy flag. Set to 1 if class was not loaded when this DXF file was
# created, and 0 otherwise
"was_a_proxy": DXFAttr(280, default=0),
# Is-an-entity flag. Set to 1 if class was derived from the AcDbEntity class
# and can reside in the BLOCKS or ENTITIES section. If 0, instances may
# appear only in the OBJECTS section
"is_an_entity": DXFAttr(281, default=0),
},
)
class_def_group_codes = group_code_mapping(class_def)
@register_entity
class DXFClass(DXFEntity):
DXFTYPE = "CLASS"
DXFATTRIBS = DXFAttributes(class_def)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
@classmethod
def new(
cls,
handle: Optional[str] = None,
owner: Optional[str] = None,
dxfattribs=None,
doc: Optional[Drawing] = None,
) -> DXFClass:
"""New CLASS constructor - has no handle, no owner and do not need
document reference .
"""
dxf_class = cls()
dxf_class.doc = doc
dxfattribs = dxfattribs or {}
dxf_class.update_dxf_attribs(dxfattribs)
return dxf_class
def load_tags(
self, tags: ExtendedTags, dxfversion: Optional[str] = None
) -> None:
"""Called by load constructor. CLASS is special."""
if tags:
# do not process base class!!!
self.dxf = DXFNamespace(entity=self)
processor = SubclassProcessor(tags)
processor.fast_load_dxfattribs(
self.dxf, class_def_group_codes, 0, log=False
)
def export_dxf(self, tagwriter: AbstractTagWriter):
"""Do complete export here, because CLASS is special."""
dxfversion = tagwriter.dxfversion
if dxfversion < DXF2000:
return
attribs = self.dxf
tagwriter.write_tag2(0, self.DXFTYPE)
attribs.export_dxf_attribs(
tagwriter,
[
"name",
"cpp_class_name",
"app_name",
"flags",
"instance_count",
"was_a_proxy",
"is_an_entity",
],
)
@property
def key(self) -> tuple[str, str]:
return self.dxf.name, self.dxf.cpp_class_name
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,728 @@
# 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
@@ -0,0 +1,455 @@
# Copyright (c) 2019-2024, Manfred Moitzi
# License: MIT-License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Iterable,
Iterator,
cast,
Union,
Optional,
)
from contextlib import contextmanager
import logging
from ezdxf.lldxf import validator, const
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.audit import AuditError
from .dxfentity import base_class, SubclassProcessor, DXFEntity
from .dxfobj import DXFObject
from .factory import register_entity
from .objectcollection import ObjectCollection
from .copy import default_copy, CopyNotSupported
if TYPE_CHECKING:
from ezdxf.audit import Auditor
from ezdxf.document import Drawing
from ezdxf.entities import DXFNamespace, Dictionary
from ezdxf.entitydb import EntityDB
from ezdxf.layouts import Layouts
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["DXFGroup", "GroupCollection"]
logger = logging.getLogger("ezdxf")
acdb_group = DefSubclass(
"AcDbGroup",
{
# Group description
"description": DXFAttr(300, default=""),
# 1 = Unnamed
# 0 = Named
"unnamed": DXFAttr(
70,
default=1,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# 1 = Selectable
# 0 = Not selectable
"selectable": DXFAttr(
71,
default=1,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# 340: Hard-pointer handle to entity in group (one entry per object)
},
)
acdb_group_group_codes = group_code_mapping(acdb_group)
GROUP_ITEM_CODE = 340
@register_entity
class DXFGroup(DXFObject):
"""Groups are not allowed in block definitions, and each entity can only
reside in one group, so cloning of groups creates also new entities.
"""
DXFTYPE = "GROUP"
DXFATTRIBS = DXFAttributes(base_class, acdb_group)
def __init__(self) -> None:
super().__init__()
self._handles: set[str] = set() # only needed at the loading stage
self._data: list[DXFEntity] = []
def copy(self, copy_strategy=default_copy):
raise CopyNotSupported("Copying of GROUP not supported.")
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.fast_load_dxfattribs(
dxf, acdb_group_group_codes, 1, log=False
)
self.load_group(tags)
return dxf
def load_group(self, tags):
for code, value in tags:
if code == GROUP_ITEM_CODE:
# First store handles, because at this point, objects
# are not stored in the EntityDB:
self._handles.add(value)
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
# remove invalid entities
assert self.doc is not None
self.purge(self.doc)
# export GROUP only if all entities reside on the same layout
if not all_entities_on_same_layout(self._data):
raise const.DXFStructureError(
"All entities have to be in the same layout and are not allowed"
" to be in a block layout."
)
return True
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_group.name)
self.dxf.export_dxf_attribs(tagwriter, ["description", "unnamed", "selectable"])
self.export_group(tagwriter)
def export_group(self, tagwriter: AbstractTagWriter):
for entity in self._data:
tagwriter.write_tag2(GROUP_ITEM_CODE, entity.dxf.handle)
def __iter__(self) -> Iterator[DXFEntity]:
"""Iterate over all DXF entities in :class:`DXFGroup` as instances of
:class:`DXFGraphic` or inherited (LINE, CIRCLE, ...).
"""
return (e for e in self._data if e.is_alive)
def __len__(self) -> int:
"""Returns the count of DXF entities in :class:`DXFGroup`."""
return len(self._data)
def __getitem__(self, item):
"""Returns entities by standard Python indexing and slicing."""
return self._data[item]
def __contains__(self, item: Union[str, DXFEntity]) -> bool:
"""Returns ``True`` if item is in :class:`DXFGroup`. `item` has to be
a handle string or an object of type :class:`DXFEntity` or inherited.
"""
handle = item if isinstance(item, str) else item.dxf.handle
return handle in set(self.handles())
def handles(self) -> Iterable[str]:
"""Iterable of handles of all DXF entities in :class:`DXFGroup`."""
return (entity.dxf.handle for entity in self)
def post_load_hook(self, doc: "Drawing"):
super().post_load_hook(doc)
db_get = doc.entitydb.get
def set_group_entities(): # post init command
name = str(self)
entities = filter_invalid_entities(self._data, self.doc, name)
if not all_entities_on_same_layout(entities):
self.clear()
logger.debug(f"Cleared {name}, had entities from different layouts.")
else:
self._data = entities
def entities():
for handle in self._handles:
entity = db_get(handle)
if entity and entity.is_alive:
yield entity
# Filtering invalid DXF entities is not possible at this stage, just
# store entities as they are:
self._data = list(entities())
del self._handles # all referenced entities are stored in data
return set_group_entities
@contextmanager # type: ignore
def edit_data(self) -> list[DXFEntity]: # type: ignore
"""Context manager which yields all the group entities as
standard Python list::
with group.edit_data() as data:
# add new entities to a group
data.append(modelspace.add_line((0, 0), (3, 0)))
# remove last entity from a group
data.pop()
"""
data = list(self)
yield data
self.set_data(data)
def _validate_entities(self, entities: Iterable[DXFEntity]) -> list[DXFEntity]:
assert self.doc is not None
entities = list(entities)
valid_entities = filter_invalid_entities(entities, self.doc, str(self))
if len(valid_entities) != len(entities):
raise const.DXFStructureError("invalid entities found")
if not all_entities_on_same_layout(valid_entities):
raise const.DXFStructureError(
"All entities have to be in the same layout and are not allowed"
" to be in a block layout."
)
return valid_entities
def set_data(self, entities: Iterable[DXFEntity]) -> None:
"""Set `entities` as new group content, entities should be an iterable of
:class:`DXFGraphic` (LINE, CIRCLE, ...).
Raises:
DXFValueError: not all entities are located on the same layout (modelspace
or any paperspace layout but not block)
"""
valid_entities = self._validate_entities(entities)
self.clear()
self._add_group_reactor(valid_entities)
self._data = valid_entities
def extend(self, entities: Iterable[DXFEntity]) -> None:
"""Add `entities` to :class:`DXFGroup`, entities should be an iterable of
:class:`DXFGraphic` (LINE, CIRCLE, ...).
Raises:
DXFValueError: not all entities are located on the same layout (modelspace
or any paperspace layout but not block)
"""
valid_entities = self._validate_entities(entities)
handles = set(self.handles())
valid_entities = [e for e in valid_entities if e.dxf.handle not in handles]
self._add_group_reactor(valid_entities)
self._data.extend(valid_entities)
def clear(self) -> None:
"""Remove all entities from :class:`DXFGroup`, does not delete any
drawing entities referenced by this group.
"""
# TODO: remove handle of GROUP entity from reactors of entities #1085
self._remove_group_reactor(self._data)
self._data = []
def _add_group_reactor(self, entities: list[DXFEntity]) -> None:
group_handle = self.dxf.handle
for entity in entities:
entity.append_reactor_handle(group_handle)
def _remove_group_reactor(self, entities: list[DXFEntity]) -> None:
group_handle = self.dxf.handle
for entity in entities:
entity.discard_reactor_handle(group_handle)
def audit(self, auditor: Auditor) -> None:
"""Remove invalid entities from :class:`DXFGroup`.
Invalid entities are:
- deleted entities
- all entities which do not reside in model- or paper space
- all entities if they do not reside in the same layout
"""
entity_count = len(self)
assert auditor.doc is not None
# Remove destroyed or invalid entities:
self.purge(auditor.doc)
removed_entity_count = entity_count - len(self)
if removed_entity_count > 0:
auditor.fixed_error(
code=AuditError.INVALID_GROUP_ENTITIES,
message=f"Removed {removed_entity_count} invalid entities from {str(self)}",
)
if not all_entities_on_same_layout(self._data):
auditor.fixed_error(
code=AuditError.GROUP_ENTITIES_IN_DIFFERENT_LAYOUTS,
message=f"Cleared {str(self)}, not all entities are located in "
f"the same layout.",
)
self.clear()
group_handle = self.dxf.handle
if not group_handle:
return
for entity in self._data:
if entity.reactors is None or group_handle not in entity.reactors:
auditor.fixed_error(
code=AuditError.MISSING_PERSISTENT_REACTOR,
message=f"Entity {entity} in group #{group_handle} does not have "
f"group as persistent reactor",
)
entity.append_reactor_handle(group_handle)
def purge(self, doc: Drawing) -> None:
"""Remove invalid group entities."""
self._data = filter_invalid_entities(
entities=self._data, doc=doc, group_name=str(self)
)
def filter_invalid_entities(
entities: Iterable[DXFEntity],
doc: Drawing,
group_name: str = "",
) -> list[DXFEntity]:
assert doc is not None
db = doc.entitydb
valid_owner_handles = valid_layout_handles(doc.layouts)
valid_entities: list[DXFEntity] = []
for entity in entities:
if entity.is_alive and _has_valid_owner(
entity.dxf.owner, db, valid_owner_handles
):
valid_entities.append(entity)
elif group_name:
if entity.is_alive:
logger.debug(f"{str(entity)} in {group_name} has an invalid owner.")
else:
logger.debug(f"Removed deleted entity in {group_name}")
return valid_entities
def _has_valid_owner(owner: str, db: EntityDB, valid_owner_handles: set[str]) -> bool:
# no owner -> no layout association
if owner is None:
return False
# The check for owner.dxf.layout != "0" is not sufficient #521
if valid_owner_handles and owner not in valid_owner_handles:
return False
layout = db.get(owner)
# owner layout does not exist or is destroyed -> no layout association
if layout is None or not layout.is_alive:
return False
# If "valid_owner_handles" is not empty, entities located on BLOCK
# layouts are already removed.
# DXF attribute block_record.layout is "0" if entity is located in a
# block definition, which is invalid:
return layout.dxf.layout != "0"
def all_entities_on_same_layout(entities: Iterable[DXFEntity]):
"""Check if all entities are on the same layout (model space or any paper
layout but not block).
"""
owners = set(entity.dxf.owner for entity in entities)
# 0 for no entities; 1 for all entities on the same layout
return len(owners) < 2
def valid_layout_handles(layouts: Layouts) -> set[str]:
"""Returns valid layout keys for group entities."""
return set(layout.layout_key for layout in layouts if layout.is_any_layout)
class GroupCollection(ObjectCollection[DXFGroup]):
def __init__(self, doc: Drawing):
super().__init__(doc, dict_name="ACAD_GROUP", object_type="GROUP")
self._next_unnamed_number = 0
def groups(self) -> Iterator[DXFGroup]:
"""Iterable of all existing groups."""
for name, group in self:
yield group
def next_name(self) -> str:
name = self._next_name()
while name in self:
name = self._next_name()
return name
def _next_name(self) -> str:
self._next_unnamed_number += 1
return f"*A{self._next_unnamed_number}"
def new(
self,
name: Optional[str] = None,
description: str = "",
selectable: bool = True,
) -> DXFGroup:
r"""Creates a new group. If `name` is ``None`` an unnamed group is
created, which has an automatically generated name like "\*Annnn".
Group names are case-insensitive.
Args:
name: group name as string
description: group description as string
selectable: group is selectable if ``True``
"""
if name is not None and name in self:
raise const.DXFValueError(f"GROUP '{name}' already exists.")
if name is None:
name = self.next_name()
unnamed = 1
else:
unnamed = 0
# The group name isn't stored in the group entity itself.
dxfattribs = {
"description": description,
"unnamed": unnamed,
"selectable": int(bool(selectable)),
}
return cast(DXFGroup, self._new(name, dxfattribs))
def delete(self, group: Union[DXFGroup, str]) -> None:
"""Delete `group`, `group` can be an object of type :class:`DXFGroup`
or a group name as string.
"""
entitydb = self.doc.entitydb
assert entitydb is not None
# Delete group by name:
if isinstance(group, str):
name = group
elif group.dxftype() == "GROUP":
name = get_group_name(group, entitydb)
else:
raise TypeError(group.dxftype())
if name in self:
super().delete(name)
else:
raise const.DXFValueError("GROUP not in group table registered.")
def audit(self, auditor: Auditor) -> None:
"""Removes empty groups and invalid handles from all groups."""
trash = []
for name, group in self:
group = cast(DXFGroup, group)
group.audit(auditor)
if not len(group): # remove empty group
# do not delete groups while iterating over groups!
trash.append(name)
# now delete empty groups
for name in trash:
auditor.fixed_error(
code=AuditError.REMOVE_EMPTY_GROUP,
message=f'Removed empty group "{name}".',
)
self.delete(name)
def get_group_name(group: DXFGroup, db: EntityDB) -> str:
"""Get name of `group`."""
group_table = cast("Dictionary", db[group.dxf.owner])
for name, entity in group_table.items():
if entity is group:
return name
return ""
@@ -0,0 +1,687 @@
# Copyright (c) 2020-2025, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Any, Optional, Union, Iterable, TYPE_CHECKING, Set
import logging
import itertools
from ezdxf import options
from ezdxf.lldxf import const
from ezdxf.lldxf.attributes import XType, DXFAttributes, DXFAttr
from ezdxf.lldxf.types import cast_value, dxftag
from ezdxf.lldxf.tags import Tags
if TYPE_CHECKING:
from ezdxf.lldxf.extendedtags import ExtendedTags
from ezdxf.entities import DXFEntity
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["DXFNamespace", "SubclassProcessor"]
logger = logging.getLogger("ezdxf")
ERR_INVALID_DXF_ATTRIB = 'Invalid DXF attribute "{}" for entity {}'
ERR_DXF_ATTRIB_NOT_EXITS = 'DXF attribute "{}" does not exist'
# supported event handler called by setting DXF attributes
# for usage, implement a method named like the dict-value, that accepts the new
# value as argument e.g.:
# Polyline.on_layer_change(name) -> changes also layers of all vertices
SETTER_EVENTS = {
"layer": "on_layer_change",
"linetype": "on_linetype_change",
"style": "on_style_change",
"dimstyle": "on_dimstyle_change",
}
EXCLUDE_FROM_UPDATE = frozenset(["_entity", "handle", "owner"])
class DXFNamespace:
""":class:`DXFNamespace` manages all named DXF attributes of an entity.
The DXFNamespace.__dict__ is used as DXF attribute storage, therefore only
valid Python names can be used as attrib name.
The namespace can only contain immutable objects: string, int, float, bool,
Vec3. Because of the immutability, copy and deepcopy are the same.
(internal class)
"""
def __init__(
self,
processor: Optional[SubclassProcessor] = None,
entity: Optional[DXFEntity] = None,
):
if processor:
base_class = processor.base_class
handle_code = 105 if base_class[0].value == "DIMSTYLE" else 5
# CLASS entities have no handle.
# TABLE entities have no handle if loaded from a DXF R12 file.
# Owner tag is None if loaded from a DXF R12 file
handle = None
owner = None
for tag in base_class:
group_code = tag.code
if group_code == handle_code:
handle = tag.value
if owner:
break
elif group_code == 330:
owner = tag.value
if handle:
break
self.rewire(entity, handle, owner)
else:
self.reset_handles()
self.rewire(entity)
def copy(self, entity: DXFEntity):
namespace = self.__class__()
for k, v in self.__dict__.items():
namespace.__dict__[k] = v
namespace.rewire(entity)
return namespace
def __deepcopy__(self, memodict: Optional[dict] = None):
return self.copy(self._entity)
def __getstate__(self) -> object:
return self.__dict__
def __setstate__(self, state: object) -> None:
if not isinstance(state, dict):
raise TypeError(f"invalid state: {type(state).__name__}")
# bypass __setattr__
object.__setattr__(self, "__dict__", state)
def reset_handles(self):
"""Reset handle and owner to None."""
self.__dict__["handle"] = None
self.__dict__["owner"] = None
def rewire(
self,
entity: Optional[DXFEntity],
handle: Optional[str] = None,
owner: Optional[str] = None,
) -> None:
"""Rewire DXF namespace with parent entity
Args:
entity: new associated entity
handle: new handle or None
owner: new entity owner handle or None
"""
# bypass __setattr__()
self.__dict__["_entity"] = entity
if handle is not None:
self.__dict__["handle"] = handle
if owner is not None:
self.__dict__["owner"] = owner
def __getattr__(self, key: str) -> Any:
"""Called if DXF attribute `key` does not exist, returns the DXF
default value or ``None``.
Raises:
DXFAttributeError: attribute `key` is not supported
"""
attrib_def: Optional[DXFAttr] = self.dxfattribs.get(key)
if attrib_def:
if attrib_def.xtype == XType.callback:
return attrib_def.get_callback_value(self._entity)
else:
return attrib_def.default
else:
raise const.DXFAttributeError(
ERR_INVALID_DXF_ATTRIB.format(key, self.dxftype)
)
def __setattr__(self, key: str, value: Any) -> None:
"""Set DXF attribute `key` to `value`.
Raises:
DXFAttributeError: attribute `key` is not supported
"""
def entity() -> str:
# DXFNamespace is maybe not assigned to the entity yet:
handle = self.get("handle")
_entity = self._entity
if _entity:
return _entity.dxftype() + f"(#{handle})"
else:
return f"#{handle}"
def check(value):
value = cast_value(attrib_def.code, value)
if not attrib_def.is_valid_value(value):
if attrib_def.fixer:
value = attrib_def.fixer(value)
logger.debug(
f'Fixed invalid attribute "{key}" in entity'
f' {entity()} to "{str(value)}".'
)
else:
raise const.DXFValueError(
f'Invalid value {str(value)} for attribute "{key}" in '
f"entity {entity()}."
)
return value
attrib_def: Optional[DXFAttr] = self.dxfattribs.get(key)
if attrib_def:
if attrib_def.xtype == XType.callback:
attrib_def.set_callback_value(self._entity, value)
else:
self.__dict__[key] = check(value)
else:
raise const.DXFAttributeError(
ERR_INVALID_DXF_ATTRIB.format(key, self.dxftype)
)
if key in SETTER_EVENTS:
handler = getattr(self._entity, SETTER_EVENTS[key], None)
if handler:
handler(value)
def __delattr__(self, key: str) -> None:
"""Delete DXF attribute `key`.
Raises:
DXFAttributeError: attribute `key` does not exist
"""
if self.hasattr(key):
del self.__dict__[key]
else:
raise const.DXFAttributeError(ERR_DXF_ATTRIB_NOT_EXITS.format(key))
def get(self, key: str, default: Any = None) -> Any:
"""Returns value of DXF attribute `key` or the given `default` value
not DXF default value for unset attributes.
Raises:
DXFAttributeError: attribute `key` is not supported
"""
# callback values should not exist as attribute in __dict__
if self.hasattr(key):
# do not return the DXF default value
return self.__dict__[key]
attrib_def: Optional["DXFAttr"] = self.dxfattribs.get(key)
if attrib_def:
if attrib_def.xtype == XType.callback:
return attrib_def.get_callback_value(self._entity)
else:
return default # return give default
else:
raise const.DXFAttributeError(
ERR_INVALID_DXF_ATTRIB.format(key, self.dxftype)
)
def get_default(self, key: str) -> Any:
"""Returns DXF default value for unset DXF attribute `key`."""
value = self.get(key, None)
return self.dxf_default_value(key) if value is None else value
def set(self, key: str, value: Any) -> None:
"""Set DXF attribute `key` to `value`.
Raises:
DXFAttributeError: attribute `key` is not supported
"""
self.__setattr__(key, value)
def unprotected_set(self, key: str, value: Any) -> None:
"""Set DXF attribute `key` to `value` without any validity checks.
Used for fast attribute setting without validity checks at loading time.
(internal API)
"""
self.__dict__[key] = value
def all_existing_dxf_attribs(self) -> dict:
"""Returns all existing DXF attributes, except DXFEntity back-link."""
attribs = dict(self.__dict__)
del attribs["_entity"]
return attribs
def update(
self,
dxfattribs: dict[str, Any],
*,
exclude: Optional[Set[str]] = None,
ignore_errors=False,
) -> None:
"""Update DXF namespace attributes from a dict."""
if exclude is None:
exclude = EXCLUDE_FROM_UPDATE # type: ignore
else: # always exclude "_entity" back-link
exclude = {"_entity"} | exclude
set_attribute = self.__setattr__
for k, v in dxfattribs.items():
if k not in exclude: # type: ignore
try:
set_attribute(k, v)
except (AttributeError, ValueError):
if not ignore_errors:
raise
def discard(self, key: str) -> None:
"""Delete DXF attribute `key` silently without any exception."""
try:
del self.__dict__[key]
except KeyError:
pass
def is_supported(self, key: str) -> bool:
"""Returns True if DXF attribute `key` is supported else False.
Does not grant that attribute `key` really exists and does not
check if the actual DXF version of the document supports this
attribute, unsupported attributes will be ignored at export.
"""
return key in self.dxfattribs
def hasattr(self, key: str) -> bool:
"""Returns True if attribute `key` really exists else False."""
return key in self.__dict__
@property
def dxftype(self) -> str:
"""Returns the DXF entity type."""
return self._entity.DXFTYPE
@property
def dxfattribs(self) -> DXFAttributes:
"""Returns the DXF attribute definition."""
return self._entity.DXFATTRIBS
def dxf_default_value(self, key: str) -> Any:
"""Returns the default value as defined in the DXF standard."""
attrib: Optional[DXFAttr] = self.dxfattribs.get(key)
if attrib:
return attrib.default
else:
return None
def export_dxf_attribs(
self, tagwriter: AbstractTagWriter, attribs: Union[str, Iterable]
) -> None:
"""Exports DXF attribute `name` by `tagwriter`. Non-optional attributes
are forced and optional tags are only written if different to default
value. DXF version check is always on: does not export DXF attribs
which are not supported by tagwriter.dxfversion.
Args:
tagwriter: tag writer object
attribs: DXF attribute name as string or an iterable of names
"""
if isinstance(attribs, str):
self._export_dxf_attribute_optional(tagwriter, attribs)
else:
for name in attribs:
self._export_dxf_attribute_optional(tagwriter, name)
def _export_dxf_attribute_optional(
self, tagwriter: AbstractTagWriter, name: str
) -> None:
"""Exports DXF attribute `name` by `tagwriter`.
Optional tags are only written if they differ from the default value.
"""
attrib: Optional[DXFAttr] = self.dxfattribs.get(name)
if attrib is None:
raise const.DXFAttributeError(
ERR_INVALID_DXF_ATTRIB.format(name, self.dxftype)
)
optional = attrib.optional
default = attrib.default
value = self.get(name, None)
# Force default value e.g. layer
if value is None and not optional:
# default value can be None!
value = default
if value is None:
logger.debug(
f"DXF attribute '{name}' not written because its a None value."
)
return
# Do not write explicit optional attribs if equal to the default value
if (
optional
and (not tagwriter.force_optional)
and default is not None
and default == value
):
return
_export_group_codes(tagwriter, attrib, value)
def export_dxf_attribute_if_exists(
self, tagwriter: AbstractTagWriter, name: str
) -> None:
"""Exports DXF attribute `name` by `tagwriter` if exists.
If the attribute exists, and it's not None it will be written, the optional-flag
is ignored.
No default value will be written if the attribute doesn't exist!
This method can not be used for attributes that are required (e.g. layer)!
"""
if not self.hasattr(name):
return
attrib: Optional[DXFAttr] = self.dxfattribs.get(name)
assert (
attrib is not None
), f"existing DXF attribute '{name}' has no definition class - internal error"
value = self.get(name)
if value is None:
logger.debug(
f"DXF attribute '{name}' not written because its a None value."
)
return
_export_group_codes(tagwriter, attrib, value)
def _export_group_codes(
tagwriter: AbstractTagWriter, attrib: DXFAttr, value: Any
) -> None:
assert attrib is not None
assert value is not None
# Do write the attribute if the export DXF version is lower than the minimal
# required DXF version for the attribute.
if tagwriter.dxfversion < attrib.dxfversion:
return
# For explicit 2D points export only x- and y-coordinates.
if attrib.xtype == XType.point2d and len(value) > 2:
try: # Vec3, Vec2
value = (value.x, value.y)
except AttributeError:
value = value[:2]
if isinstance(value, str):
assert "\n" not in value, "line break '\\n' not allowed"
assert "\r" not in value, "line break '\\r' not allowed"
tag = dxftag(attrib.code, value)
tagwriter.write_tag(tag)
BASE_CLASS_CODES = {0, 5, 102, 330}
class SubclassProcessor:
"""Helper class for loading tags into entities. (internal class)"""
def __init__(self, tags: ExtendedTags, dxfversion: Optional[str] = None):
if len(tags.subclasses) == 0:
raise ValueError("Invalid tags.")
self.subclasses: list[Tags] = list(tags.subclasses) # copy subclasses
self.embedded_objects: list[Tags] = tags.embedded_objects or []
self.dxfversion: Optional[str] = dxfversion
# DXF R12 and prior have no subclass marker system, all tags of an
# entity in one flat list.
# Later DXF versions have at least 2 subclasses base_class and
# AcDbEntity.
# Exception: CLASS has also only one subclass and no subclass marker,
# handled as DXF R12 entity
self.r12: bool = (dxfversion == const.DXF12) or (len(self.subclasses) == 1)
self.name: str = tags.dxftype()
self.handle: str
try:
self.handle = tags.get_handle()
except const.DXFValueError:
self.handle = "<?>"
@property
def base_class(self):
return self.subclasses[0]
def log_unprocessed_tags(
self,
unprocessed_tags: Iterable,
subclass="<?>",
handle: Optional[str] = None,
) -> None:
if options.log_unprocessed_tags:
for tag in unprocessed_tags:
entity = ""
if handle:
entity = f" in entity #{handle}"
logger.info(f"ignored {repr(tag)} in subclass {subclass}" + entity)
def find_subclass(self, name: str) -> Optional[Tags]:
for subclass in self.subclasses:
if len(subclass) and subclass[0].value == name:
return subclass
return None
def subclass_by_index(self, index: int) -> Optional[Tags]:
try:
return self.subclasses[index]
except IndexError:
return None
def detect_implementation_version(
self, subclass_index: int, group_code: int, default: int
) -> int:
subclass = self.subclass_by_index(subclass_index)
if subclass and len(subclass) > 1:
# the version tag has to be the 2nd tag after the subclass marker
tag = subclass[1]
if tag.code == group_code:
return tag.value
return default
# TODO: rename to complex_dxfattribs_loader()
def fast_load_dxfattribs(
self,
dxf: DXFNamespace,
group_code_mapping: dict[int, Union[str, list]],
subclass: Union[int, str, Tags],
*,
recover=False,
log=True,
) -> Tags:
"""Load DXF attributes into the DXF namespace and returns the
unprocessed tags without leading subclass marker(100, AcDb...).
Bypasses the DXF attribute validity checks.
Args:
dxf: entity DXF namespace
group_code_mapping: group code to DXF attribute name mapping,
callback attributes have to be marked with a leading "*"
subclass: subclass by index, by name or as Tags()
recover: recover graphic attributes
log: enable/disable logging of unprocessed tags
"""
if self.r12:
tags = self.subclasses[0]
else:
if isinstance(subclass, int):
tags = self.subclass_by_index(subclass) # type: ignore
elif isinstance(subclass, str):
tags = self.find_subclass(subclass) # type: ignore
else:
tags = subclass
unprocessed_tags = Tags()
if tags is None or len(tags) == 0:
return unprocessed_tags
processed_names: set[str] = set()
# Localize attributes:
get_attrib_name = group_code_mapping.get
append_unprocessed_tag = unprocessed_tags.append
unprotected_set_attrib = dxf.unprotected_set
mark_attrib_as_processed = processed_names.add
# Ignore (100, "AcDb...") or (0, "ENTITY") tag in case of DXF R12
start = 1 if tags[0].code in (0, 100) else 0
for tag in tags[start:]:
name = get_attrib_name(tag.code)
if isinstance(name, list): # process group code duplicates:
names = name
# If all existing attrib names are used, treat this tag
# like an unprocessed tag.
name = None
# The attribute names are processed in the order of their
# definition:
for name_ in names:
if name_ not in processed_names:
name = name_
mark_attrib_as_processed(name_)
break
if name:
# Ignore callback attributes and group codes explicit marked
# as "*IGNORE":
if name[0] != "*":
unprotected_set_attrib(
name, cast_value(tag.code, tag.value) # type: ignore
)
else:
append_unprocessed_tag(tag)
if self.r12:
# R12 has always unprocessed tags, because there are all tags in one
# subclass and one subclass definition never covers all tags e.g.
# handle is processed in DXFEntity, so it is an unprocessed tag in
# AcDbEntity.
return unprocessed_tags
# Only DXF R13+
if recover and len(unprocessed_tags):
# TODO: maybe obsolete if simple_dxfattribs_loader() is used for
# most old DXF R12 entities
unprocessed_tags = recover_graphic_attributes(unprocessed_tags, dxf)
if len(unprocessed_tags) and log:
# First tag is the subclass specifier (100, "AcDb...")
name = tags[0].value
self.log_unprocessed_tags(
unprocessed_tags, subclass=name, handle=dxf.get("handle")
)
return unprocessed_tags
def append_base_class_to_acdb_entity(self) -> None:
"""It is valid to mix up the base class with AcDbEntity class.
This method appends all none base class group codes to the
AcDbEntity class.
"""
# This is only needed for DXFEntity, so applying this method
# automatically to all entities is waste of runtime
# -> DXFGraphic.load_dxf_attribs()
# TODO: maybe obsolete if simple_dxfattribs_loader() is used for
# most old DXF R12 entities
if self.r12:
return
acdb_entity_tags = self.subclasses[1]
if acdb_entity_tags[0] == (100, "AcDbEntity"):
acdb_entity_tags.extend(
tag for tag in self.subclasses[0] if tag.code not in BASE_CLASS_CODES
)
def simple_dxfattribs_loader(
self, dxf: DXFNamespace, group_code_mapping: dict[int, str]
) -> None:
# tested in test suite 201 for the POINT entity
"""Load DXF attributes from all subclasses into the DXF namespace.
Can not handle same group codes in different subclasses, does not remove
processed tags or log unprocessed tags and bypasses the DXF attribute
validity checks.
This method ignores the subclass structure and can load data from
very malformed DXF files, like such in issue #604.
This method works only for very simple DXF entities with unique group
codes in all subclasses, the old DXF R12 entities:
- POINT
- LINE
- CIRCLE
- ARC
- INSERT
- SHAPE
- SOLID/TRACE/3DFACE
- TEXT (ATTRIB/ATTDEF bypasses TEXT loader)
- BLOCK/ENDBLK
- POLYLINE/VERTEX/SEQEND
- DIMENSION and subclasses
- all table entries: LAYER, LTYPE, ...
And the newer DXF entities:
- ELLIPSE
- RAY/XLINE
The recover mode for graphical attributes is automatically included.
Logging of unprocessed tags is not possible but also not required for
this simple and well known entities.
Args:
dxf: entity DXF namespace
group_code_mapping: group code name mapping for all DXF attributes
from all subclasses, callback attributes have to be marked with
a leading "*"
"""
tags = itertools.chain.from_iterable(self.subclasses)
get_attrib_name = group_code_mapping.get
unprotected_set_attrib = dxf.unprotected_set
for tag in tags:
name = get_attrib_name(tag.code)
if isinstance(name, str) and not name.startswith("*"):
unprotected_set_attrib(name, cast_value(tag.code, tag.value))
GRAPHIC_ATTRIBUTES_TO_RECOVER = {
8: "layer",
6: "linetype",
62: "color",
67: "paperspace",
370: "lineweight",
48: "ltscale",
60: "invisible",
420: "true_color",
430: "color_name",
440: "transparency",
284: "shadow_mode",
347: "material_handle",
348: "visualstyle_handle",
380: "plotstyle_enum",
390: "plotstyle_handle",
}
# TODO: maybe obsolete if simple_dxfattribs_loader() is used for
# most old DXF R12 entities
def recover_graphic_attributes(tags: Tags, dxf: DXFNamespace) -> Tags:
unprocessed_tags = Tags()
for tag in tags:
attrib_name = GRAPHIC_ATTRIBUTES_TO_RECOVER.get(tag.code)
# Don't know if the unprocessed tag is really a misplaced tag,
# so check if the attribute already exist!
if attrib_name and not dxf.hasattr(attrib_name):
dxf.set(attrib_name, tag.value)
else:
unprocessed_tags.append(tag)
return unprocessed_tags
@@ -0,0 +1,400 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Union, Any, Optional
from typing_extensions import Self, TypeGuard
import logging
import array
from ezdxf.lldxf import validator
from ezdxf.lldxf.const import DXF2000, DXFStructureError, SUBCLASS_MARKER
from ezdxf.lldxf.tags import Tags
from ezdxf.lldxf.types import dxftag, DXFTag, DXFBinaryTag
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.tools import take2
from .dxfentity import DXFEntity, base_class, SubclassProcessor, DXFTagStorage
from .factory import register_entity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.audit import Auditor
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = [
"DXFObject",
"Placeholder",
"XRecord",
"VBAProject",
"SortEntsTable",
"Field",
"is_dxf_object",
]
logger = logging.getLogger("ezdxf")
class DXFObject(DXFEntity):
"""Non-graphical entities stored in the OBJECTS section."""
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
@register_entity
class Placeholder(DXFObject):
DXFTYPE = "ACDBPLACEHOLDER"
acdb_xrecord = DefSubclass(
"AcDbXrecord",
{
# 0 = not applicable
# 1 = keep existing
# 2 = use clone
# 3 = <xref>$0$<name>
# 4 = $0$<name>
# 5 = Unmangle name
"cloning": DXFAttr(
280,
default=1,
validator=validator.is_in_integer_range(0, 6),
fixer=RETURN_DEFAULT,
),
},
)
def totags(tags: Iterable) -> Iterable[DXFTag]:
for tag in tags:
if isinstance(tag, DXFTag):
yield tag
else:
yield dxftag(tag[0], tag[1])
@register_entity
class XRecord(DXFObject):
"""DXF XRECORD entity"""
DXFTYPE = "XRECORD"
DXFATTRIBS = DXFAttributes(base_class, acdb_xrecord)
def __init__(self):
super().__init__()
self.tags = Tags()
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, XRecord)
entity.tags = Tags(self.tags)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
try:
tags = processor.subclasses[1]
except IndexError:
raise DXFStructureError(
f"Missing subclass AcDbXrecord in XRecord (#{dxf.handle})"
)
start_index = 1
if len(tags) > 1:
# First tag is group code 280, but not for DXF R13/R14.
# SUT: doc may be None, but then doc also can not
# be R13/R14 - ezdxf does not create R13/R14
if self.doc is None or self.doc.dxfversion >= DXF2000:
code, value = tags[1]
if code == 280:
dxf.cloning = value
start_index = 2
else: # just log recoverable error
logger.info(
f"XRecord (#{dxf.handle}): expected group code 280 "
f"as first tag in AcDbXrecord"
)
self.tags = Tags(tags[start_index:])
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_xrecord.name)
tagwriter.write_tag2(280, self.dxf.cloning)
tagwriter.write_tags(Tags(totags(self.tags)))
def reset(self, tags: Iterable[Union[DXFTag, tuple[int, Any]]]) -> None:
"""Reset DXF tags."""
self.tags.clear()
self.tags.extend(totags(tags))
def extend(self, tags: Iterable[Union[DXFTag, tuple[int, Any]]]) -> None:
"""Extend DXF tags."""
self.tags.extend(totags(tags))
def clear(self) -> None:
"""Remove all DXF tags."""
self.tags.clear()
acdb_vba_project = DefSubclass(
"AcDbVbaProject",
{
# 90: Number of bytes of binary chunk data (contained in the group code
# 310 records that follow)
# 310: DXF: Binary object data (multiple entries containing VBA project
# data)
},
)
@register_entity
class VBAProject(DXFObject):
"""DXF VBA_PROJECT entity"""
DXFTYPE = "VBA_PROJECT"
DXFATTRIBS = DXFAttributes(base_class, acdb_vba_project)
def __init__(self):
super().__init__()
self.data = b""
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, VBAProject)
entity.data = entity.data
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
self.load_byte_data(processor.subclasses[1])
return dxf
def load_byte_data(self, tags: Tags) -> None:
byte_array = array.array("B")
# Translation from String to binary data happens in tag_compiler():
for byte_data in (tag.value for tag in tags if tag.code == 310):
byte_array.extend(byte_data)
self.data = byte_array.tobytes()
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_vba_project.name)
tagwriter.write_tag2(90, len(self.data))
self.export_data(tagwriter)
def export_data(self, tagwriter: AbstractTagWriter):
data = self.data
while data:
tagwriter.write_tag(DXFBinaryTag(310, data[:127]))
data = data[127:]
def clear(self) -> None:
self.data = b""
acdb_sort_ents_table = DefSubclass(
"AcDbSortentsTable",
{
# Soft-pointer ID/handle to owner (currently only the *MODEL_SPACE or
# *PAPER_SPACE blocks) in ezdxf the block_record handle for a layout is
# also called layout_key:
"block_record_handle": DXFAttr(330),
# 331: Soft-pointer ID/handle to an entity (zero or more entries may exist)
# 5: Sort handle (zero or more entries may exist)
},
)
acdb_sort_ents_table_group_codes = group_code_mapping(acdb_sort_ents_table)
@register_entity
class SortEntsTable(DXFObject):
"""DXF SORTENTSTABLE entity - sort entities table"""
# should work with AC1015/R2000 but causes problems with TrueView/AutoCAD
# LT 2019: "expected was-a-zombie-flag"
# No problems with AC1018/R2004 and later
#
# If the header variable $SORTENTS Regen flag (bit-code value 16) is set,
# AutoCAD regenerates entities in ascending handle order.
#
# When the DRAWORDER command is used, a SORTENTSTABLE object is attached to
# the *Model_Space or *Paper_Space block's extension dictionary under the
# name ACAD_SORTENTS. The SORTENTSTABLE object related to this dictionary
# associates a different handle with each entity, which redefines the order
# in which the entities are regenerated.
#
# $SORTENTS (280): Controls the object sorting methods (bitcode):
# 0 = Disables SORTENTS
# 1 = Sorts for object selection
# 2 = Sorts for object snap
# 4 = Sorts for redraws; obsolete
# 8 = Sorts for MSLIDE command slide creation; obsolete
# 16 = Sorts for REGEN commands
# 32 = Sorts for plotting
# 64 = Sorts for PostScript output; obsolete
DXFTYPE = "SORTENTSTABLE"
DXFATTRIBS = DXFAttributes(base_class, acdb_sort_ents_table)
def __init__(self) -> None:
super().__init__()
self.table: dict[str, str] = dict()
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, SortEntsTable)
entity.table = dict(entity.table)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.fast_load_dxfattribs(
dxf, acdb_sort_ents_table_group_codes, 1, log=False
)
self.load_table(tags)
return dxf
def load_table(self, tags: Tags) -> None:
for handle, sort_handle in take2(tags):
if handle.code != 331:
raise DXFStructureError(
f"Invalid handle code {handle.code}, expected 331"
)
if sort_handle.code != 5:
raise DXFStructureError(
f"Invalid sort handle code {handle.code}, expected 5"
)
self.table[handle.value] = sort_handle.value
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_sort_ents_table.name)
tagwriter.write_tag2(330, self.dxf.block_record_handle)
self.export_table(tagwriter)
def export_table(self, tagwriter: AbstractTagWriter):
for handle, sort_handle in self.table.items():
tagwriter.write_tag2(331, handle)
tagwriter.write_tag2(5, sort_handle)
def __len__(self) -> int:
return len(self.table)
def __iter__(self) -> Iterable:
"""Yields all redraw associations as (object_handle, sort_handle)
tuples.
"""
return iter(self.table.items())
def append(self, handle: str, sort_handle: str) -> None:
"""Append redraw association (handle, sort_handle).
Args:
handle: DXF entity handle (uppercase hex value without leading '0x')
sort_handle: sort handle (uppercase hex value without leading '0x')
"""
self.table[handle] = sort_handle
def clear(self):
"""Remove all handles from redraw order table."""
self.table = dict()
def set_handles(self, handles: Iterable[tuple[str, str]]) -> None:
"""Set all redraw associations from iterable `handles`, after removing
all existing associations.
Args:
handles: iterable yielding (object_handle, sort_handle) tuples
"""
# The sort_handle doesn't have to be unique, same or all handles can
# share the same sort_handle and sort_handles can use existing handles
# too.
#
# The '0' handle can be used, but this sort_handle will be drawn as
# latest (on top of all other entities) and not as first as expected.
# Invalid entity handles will be ignored by AutoCAD.
self.table = dict(handles)
def remove_invalid_handles(self) -> None:
"""Remove all handles which do not exist in the drawing database."""
if self.doc is None:
return
entitydb = self.doc.entitydb
self.table = {
handle: sort_handle
for handle, sort_handle in self.table.items()
if handle in entitydb
}
def remove_handle(self, handle: str) -> None:
"""Remove handle of DXF entity from redraw order table.
Args:
handle: DXF entity handle (uppercase hex value without leading '0x')
"""
try:
del self.table[handle]
except KeyError:
pass
acdb_field = DefSubclass(
"AcDbField",
{
"evaluator_id": DXFAttr(1),
"field_code": DXFAttr(2),
# Overflow of field code string
"field_code_overflow": DXFAttr(3),
# Number of child fields
"n_child_fields": DXFAttr(90),
# 360: Child field ID (AcDbHardOwnershipId); repeats for number of children
# 97: Number of object IDs used in the field code
# 331: Object ID used in the field code (AcDbSoftPointerId); repeats for
# the number of object IDs used in the field code
# 93: Number of the data set in the field
# 6: Key string for the field data; a key-field pair is repeated for the
# number of data sets in the field
# 7: Key string for the evaluated cache; this key is hard-coded
# as ACFD_FIELD_VALUE
# 90: Data type of field value
# 91: Long value (if data type of field value is long)
# 140: Double value (if data type of field value is double)
# 330: ID value, AcDbSoftPointerId (if data type of field value is ID)
# 92: Binary data buffer size (if data type of field value is binary)
# 310: Binary data (if data type of field value is binary)
# 301: Format string
# 9: Overflow of Format string
# 98: Length of format string
},
)
# todo: implement FIELD
# register when done
class Field(DXFObject):
"""DXF FIELD entity"""
DXFTYPE = "FIELD"
DXFATTRIBS = DXFAttributes(base_class, acdb_field)
def is_dxf_object(entity: DXFEntity) -> TypeGuard[DXFObject]:
"""Returns ``True`` if the `entity` is a DXF object from the OBJECTS section,
otherwise the entity is a table or class entry or a graphic entity which can
not reside in the OBJECTS section.
"""
if isinstance(entity, DXFObject):
return True
if isinstance(entity, DXFTagStorage) and not entity.is_graphic_entity:
return True
return False
@@ -0,0 +1,320 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Optional
import math
from ezdxf.audit import AuditError
from ezdxf.lldxf import validator
from ezdxf.math import (
Vec3,
Matrix44,
NULLVEC,
X_AXIS,
Z_AXIS,
ellipse,
ConstructionEllipse,
OCS,
)
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
merge_group_code_mappings,
)
from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2000
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import (
DXFGraphic,
acdb_entity,
add_entity,
replace_entity,
acdb_entity_group_codes,
)
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.entities import DXFNamespace, Spline
from ezdxf.audit import Auditor
__all__ = ["Ellipse"]
MIN_RATIO = 1e-10 # tested with DWG TrueView 2022
MAX_RATIO = 1.0 # tested with DWG TrueView 2022
TOL = 1e-9 # arbitrary choice
def is_valid_ratio(ratio: float) -> bool:
"""Check if axis-ratio is in valid range, takes an upper bound tolerance into
account for floating point imprecision.
"""
return MIN_RATIO <= abs(ratio) < (MAX_RATIO + TOL)
def clamp_axis_ratio(ratio: float) -> float:
"""Clamp axis-ratio into valid range and remove possible floating point imprecision.
"""
sign = -1 if ratio < 0 else +1
ratio = abs(ratio)
if ratio < MIN_RATIO:
return MIN_RATIO * sign
if ratio > MAX_RATIO:
return MAX_RATIO * sign
return ratio * sign
acdb_ellipse = DefSubclass(
"AcDbEllipse",
{
"center": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
# Major axis vector from 'center':
"major_axis": DXFAttr(
11,
xtype=XType.point3d,
default=X_AXIS,
validator=validator.is_not_null_vector,
),
# The extrusion vector does not establish an OCS, it is just the normal
# vector of the ellipse plane:
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# Ratio has to be in the range: -1.0 ... -1e-10 and +1e-10 ... +1.0:
"ratio": DXFAttr(
40, default=MAX_RATIO, validator=is_valid_ratio, fixer=clamp_axis_ratio
),
# Start of ellipse, this value is 0.0 for a full ellipse:
"start_param": DXFAttr(41, default=0),
# End of ellipse, this value is 2π for a full ellipse:
"end_param": DXFAttr(42, default=math.tau),
},
)
acdb_ellipse_group_code = group_code_mapping(acdb_ellipse)
merged_ellipse_group_codes = merge_group_code_mappings(
acdb_entity_group_codes, acdb_ellipse_group_code # type: ignore
)
HALF_PI = math.pi / 2.0
@register_entity
class Ellipse(DXFGraphic):
"""DXF ELLIPSE entity"""
DXFTYPE = "ELLIPSE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_ellipse)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
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, merged_ellipse_group_codes)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_ellipse.name)
# is_valid_ratio() takes floating point imprecision on the upper bound of
# +/- 1.0 into account:
assert is_valid_ratio(
self.dxf.ratio
), f"axis-ratio out of range [{MIN_RATIO}, {MAX_RATIO}]"
self.dxf.ratio = clamp_axis_ratio(self.dxf.ratio)
self.dxf.export_dxf_attribs(
tagwriter,
[
"center",
"major_axis",
"extrusion",
"ratio",
"start_param",
"end_param",
],
)
@property
def minor_axis(self) -> Vec3:
dxf = self.dxf
return ellipse.minor_axis(Vec3(dxf.major_axis), Vec3(dxf.extrusion), dxf.ratio)
@property
def start_point(self) -> Vec3:
return list(self.vertices([self.dxf.start_param]))[0]
@property
def end_point(self) -> Vec3:
return list(self.vertices([self.dxf.end_param]))[0]
def construction_tool(self) -> ConstructionEllipse:
"""Returns construction tool :class:`ezdxf.math.ConstructionEllipse`."""
dxf = self.dxf
return ConstructionEllipse(
dxf.center,
dxf.major_axis,
dxf.extrusion,
dxf.ratio,
dxf.start_param,
dxf.end_param,
)
def apply_construction_tool(self, e: ConstructionEllipse) -> Ellipse:
"""Set ELLIPSE data from construction tool
:class:`ezdxf.math.ConstructionEllipse`.
"""
self.update_dxf_attribs(e.dxfattribs())
return self # floating interface
def params(self, num: int) -> Iterable[float]:
"""Returns `num` params from start- to end param in counter-clockwise
order.
All params are normalized in the range [0, 2π).
"""
start = self.dxf.start_param % math.tau
end = self.dxf.end_param % math.tau
yield from ellipse.get_params(start, end, num)
def vertices(self, params: Iterable[float]) -> Iterable[Vec3]:
"""Yields vertices on ellipse for iterable `params` in WCS.
Args:
params: param values in the range from 0 to 2π in radians,
param goes counter-clockwise around the extrusion vector,
major_axis = local x-axis = 0 rad.
"""
yield from self.construction_tool().vertices(params)
def flattening(self, distance: float, segments: int = 8) -> Iterable[Vec3]:
"""Adaptive recursive flattening. The argument `segments` is the
minimum count of approximation segments, if the distance from the center
of the approximation segment to the curve is bigger than `distance` the
segment will be subdivided. Returns a closed polygon for a full ellipse
where the start vertex has the same value as the end vertex.
Args:
distance: maximum distance from the projected curve point onto the
segment chord.
segments: minimum segment count
"""
return self.construction_tool().flattening(distance, segments)
def swap_axis(self):
"""Swap axis and adjust start- and end parameter."""
e = self.construction_tool()
e.swap_axis()
self.update_dxf_attribs(e.dxfattribs())
@classmethod
def from_arc(cls, entity: DXFGraphic) -> Ellipse:
"""Create a new virtual ELLIPSE entity from an ARC or a CIRCLE entity.
The new entity has no owner, no handle, is not stored in the entity database nor
assigned to any layout!
"""
assert entity.dxftype() in {"ARC", "CIRCLE"}, "ARC or CIRCLE entity required"
attribs = entity.dxfattribs(drop={"owner", "handle", "thickness"})
e = ellipse.ConstructionEllipse.from_arc(
center=attribs.get("center", NULLVEC),
extrusion=attribs.get("extrusion", Z_AXIS),
# Remove all not ELLIPSE attributes:
radius=attribs.pop("radius", 1.0),
start_angle=attribs.pop("start_angle", 0),
end_angle=attribs.pop("end_angle", 360),
)
attribs.update(e.dxfattribs())
return cls.new(dxfattribs=attribs, doc=entity.doc)
def transform(self, m: Matrix44) -> Ellipse:
"""Transform the ELLIPSE entity by transformation matrix `m` inplace."""
e = self.construction_tool()
e.transform(m)
self.update_dxf_attribs(e.dxfattribs())
self.post_transform(m)
return self
def translate(self, dx: float, dy: float, dz: float) -> Ellipse:
"""Optimized ELLIPSE translation about `dx` in x-axis, `dy` in y-axis
and `dz` in z-axis, returns `self` (floating interface).
"""
self.dxf.center = Vec3(dx, dy, dz) + self.dxf.center
# Avoid Matrix44 instantiation if not required:
if self.is_post_transform_required:
self.post_transform(Matrix44.translate(dx, dy, dz))
return self
def to_spline(self, replace=True) -> Spline:
"""Convert ELLIPSE to a :class:`~ezdxf.entities.Spline` entity.
Adds the new SPLINE entity to the entity database and to the
same layout as the source entity.
Args:
replace: replace (delete) source entity by SPLINE entity if ``True``
"""
from ezdxf.entities import Spline
spline = Spline.from_arc(self)
layout = self.get_layout()
assert layout is not None, "valid layout required"
if replace:
replace_entity(self, spline, layout)
else:
add_entity(spline, layout)
return spline
def ocs(self) -> OCS:
# WCS entity which supports the "extrusion" attribute in a
# different way!
return OCS()
def audit(self, auditor: Auditor) -> None:
if not self.is_alive:
return
super().audit(auditor)
entity = str(self)
major_axis = Vec3(self.dxf.major_axis)
if major_axis.is_null:
auditor.trash(self)
auditor.fixed_error(
code=AuditError.INVALID_MAJOR_AXIS,
message=f"Removed {entity} with invalid major axis: (0, 0, 0).",
)
return
axis_ratio = self.dxf.ratio
if is_valid_ratio(axis_ratio):
# remove possible floating point imprecision:
self.dxf.ratio = clamp_axis_ratio(axis_ratio)
return
if abs(axis_ratio) > MAX_RATIO:
self.swap_axis()
auditor.fixed_error(
code=AuditError.INVALID_ELLIPSE_RATIO,
message=f"Fixed invalid axis-ratio in {entity} by swapping axis.",
)
elif abs(axis_ratio) < MIN_RATIO:
self.dxf.ratio = clamp_axis_ratio(axis_ratio)
auditor.fixed_error(
code=AuditError.INVALID_ELLIPSE_RATIO,
message=f"Fixed invalid axis-ratio in {entity}, set to {MIN_RATIO}.",
)
@@ -0,0 +1,136 @@
# Copyright (c) 2019-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import DXFEntity
from ezdxf.lldxf.extendedtags import ExtendedTags
__all__ = [
"register_entity",
"ENTITY_CLASSES",
"replace_entity",
"new",
"cls",
"is_bound",
"create_db_entry",
"load",
"bind",
]
# Stores all registered classes:
ENTITY_CLASSES = {}
# use @set_default_class to register the default entity class:
DEFAULT_CLASS = None
def set_default_class(cls):
global DEFAULT_CLASS
DEFAULT_CLASS = cls
return cls
def replace_entity(cls):
name = cls.DXFTYPE
ENTITY_CLASSES[name] = cls
return cls
def register_entity(cls):
name = cls.DXFTYPE
if name in ENTITY_CLASSES:
raise TypeError(f"Double registration for DXF type {name}.")
ENTITY_CLASSES[name] = cls
return cls
def new(
dxftype: str, dxfattribs=None, doc: Optional[Drawing] = None
) -> DXFEntity:
"""Create a new entity, does not require an instantiated DXF document."""
entity = cls(dxftype).new(
handle=None,
owner=None,
dxfattribs=dxfattribs,
doc=doc,
)
return entity.cast() if hasattr(entity, "cast") else entity
def create_db_entry(dxftype, dxfattribs, doc: Drawing) -> DXFEntity:
entity = new(dxftype=dxftype, dxfattribs=dxfattribs)
bind(entity, doc)
return entity
def load(tags: ExtendedTags, doc: Optional[Drawing] = None) -> DXFEntity:
entity = cls(tags.dxftype()).load(tags, doc)
return entity.cast() if hasattr(entity, "cast") else entity
def cls(dxftype: str) -> DXFEntity:
"""Returns registered class for `dxftype`."""
return ENTITY_CLASSES.get(dxftype, DEFAULT_CLASS)
def bind(entity: DXFEntity, doc: Drawing) -> None:
"""Bind `entity` to the DXF document `doc`.
The bind process stores the DXF `entity` in the entity database of the DXF
document.
"""
assert entity.is_alive, "Can not bind destroyed entity."
assert doc.entitydb is not None, "Missing entity database."
entity.doc = doc
doc.entitydb.add(entity)
# Do not call the post_bind_hook() while loading from external sources,
# not all entities and resources are loaded at this point of time!
if not doc.is_loading: # type: ignore
# bind extension dictionary
if entity.extension_dict is not None:
xdict = entity.extension_dict
if xdict.has_valid_dictionary:
xdict.update_owner(entity.dxf.handle)
dictionary = xdict.dictionary
if not is_bound(dictionary, doc):
bind(dictionary, doc)
doc.objects.add_object(dictionary)
entity.post_bind_hook()
def unbind(entity: DXFEntity):
"""Unbind `entity` from document and layout, but does not destroy the
entity.
Turns `entity` into a virtual entity: no handle, no owner, no document.
"""
if entity.is_alive and not entity.is_virtual:
doc = entity.doc
if entity.dxf.owner is not None:
try:
layout = doc.layouts.get_layout_for_entity(entity) # type: ignore
except KeyError:
pass
else:
layout.unlink_entity(entity) # type: ignore
process_sub_entities = getattr(entity, "process_sub_entities", None)
if process_sub_entities:
process_sub_entities(lambda e: unbind(e))
doc.entitydb.discard(entity) # type: ignore
entity.doc = None
def is_bound(entity: DXFEntity, doc: Drawing) -> bool:
"""Returns ``True`` if `entity`is bound to DXF document `doc`."""
if not entity.is_alive:
return False
if entity.is_virtual or entity.doc is not doc:
return False
assert doc.entitydb, "Missing entity database."
return entity.dxf.handle in doc.entitydb
@@ -0,0 +1,623 @@
# 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)
@@ -0,0 +1,124 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
import enum
import math
from ezdxf.colors import RGB, int2rgb, rgb2int
from ezdxf.lldxf.tags import Tags
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["Gradient", "GradientType"]
GRADIENT_CODES = {450, 451, 452, 453, 460, 461, 462, 463, 470, 421, 63}
class GradientType(enum.IntEnum):
NONE = 0
LINEAR = 1
CYLINDER = 2
INVCYLINDER = 3
SPHERICAL = 4
INVSPHERICAL = 5
HEMISPHERICAL = 6
INVHEMISPHERICAL = 7
CURVED = 8
INVCURVED = 9
gradient_names = [
"",
"LINEAR",
"CYLINDER",
"INVCYLINDER",
"SPHERICAL",
"INVSPHERICAL",
"HEMISPHERICAL",
"INVHEMISPHERICAL",
"CURVED",
"INVCURVED",
]
class Gradient:
def __init__(self, kind: int = 1, num: int = 2, type=GradientType.LINEAR):
# 1 for gradient by default, 0 for Solid
self.kind: int = kind
self.number_of_colors: int = num
self.color1: RGB = RGB(0, 0, 0)
self.aci1: Optional[int] = None
self.color2: RGB = RGB(255, 255, 255)
self.aci2: Optional[int] = None
# 1 = use a smooth transition between color1 and a specified tint
self.one_color: int = 0
# Use degree NOT radians for rotation, because there should be one
# system for all angles:
self.rotation: float = 0.0
self.centered: float = 0.0
self.tint: float = 0.0
self.name: str = gradient_names[type]
@classmethod
def load_tags(cls, tags: Tags) -> Gradient:
gdata = cls()
assert tags[0].code == 450
gdata.kind = tags[0].value # 0 = solid; 1 = gradient
first_color_value = True
first_aci_value = True
for code, value in tags:
if code == 460:
gdata.rotation = math.degrees(value)
elif code == 461:
gdata.centered = value
elif code == 452:
gdata.one_color = value
elif code == 462:
gdata.tint = value
elif code == 470:
gdata.name = value
elif code == 453:
gdata.number_of_colors = value
elif code == 63:
if first_aci_value:
gdata.aci1 = value
first_aci_value = False
else:
gdata.aci2 = value
elif code == 421:
if first_color_value:
gdata.color1 = int2rgb(value)
first_color_value = False
else:
gdata.color2 = int2rgb(value)
return gdata
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
# Tag order matters!
write_tag = tagwriter.write_tag2
write_tag(450, self.kind) # gradient or solid
write_tag(451, 0) # reserved for the future
# rotation angle in radians:
write_tag(460, math.radians(self.rotation))
write_tag(461, self.centered)
write_tag(452, self.one_color)
write_tag(462, self.tint)
write_tag(453, self.number_of_colors)
if self.number_of_colors > 0:
write_tag(463, 0) # first value, see DXF standard
if self.aci1 is not None:
# code 63 "color as ACI" could be left off
write_tag(63, self.aci1)
write_tag(421, rgb2int(self.color1)) # first color
if self.number_of_colors > 1:
write_tag(463, 1) # second value, see DXF standard
if self.aci2 is not None:
# code 63 "color as ACI" could be left off
write_tag(63, self.aci2)
write_tag(421, rgb2int(self.color2)) # second color
write_tag(470, self.name)
@@ -0,0 +1,323 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Optional
from ezdxf.lldxf import const
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.tags import Tags
from ezdxf.math import NULLVEC, Z_AXIS
from .boundary_paths import AbstractBoundaryPath
from .dxfentity import base_class
from .dxfgfx import acdb_entity
from .factory import register_entity
from .polygon import DXFPolygon
if TYPE_CHECKING:
from ezdxf.colors import RGB
from ezdxf.document import Drawing
from ezdxf.entities import DXFEntity
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["Hatch"]
acdb_hatch = DefSubclass(
"AcDbHatch",
{
# This subclass can also represent a MPolygon, whatever this is, never seen
# such a MPolygon in the wild.
# x- and y-axis always equal 0, z-axis represents the elevation:
"elevation": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# Hatch pattern name:
"pattern_name": DXFAttr(2, default="SOLID"),
# HATCH: Solid fill flag:
# 0 = pattern fill
# 1 = solid fill
"solid_fill": DXFAttr(
70,
default=1,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# HATCH: associativity flag
# 0 = non-associative
# 1 = associative
"associative": DXFAttr(
71,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# 91: Number of boundary paths (loops)
# following: Boundary path data. Repeats number of times specified by
# code 91. See Boundary Path Data
# Hatch style:
# 0 = Hatch “odd parity” area (Normal style)
# 1 = Hatch outermost area only (Outer style)
# 2 = Hatch through entire area (Ignore style)
"hatch_style": DXFAttr(
75,
default=const.HATCH_STYLE_NESTED,
validator=validator.is_in_integer_range(0, 3),
fixer=RETURN_DEFAULT,
),
# Hatch pattern type:
# 0 = User-defined
# 1 = Predefined
# 2 = Custom
"pattern_type": DXFAttr(
76,
default=const.HATCH_TYPE_PREDEFINED,
validator=validator.is_in_integer_range(0, 3),
fixer=RETURN_DEFAULT,
),
# Hatch pattern angle (pattern fill only) in degrees:
"pattern_angle": DXFAttr(52, default=0),
# Hatch pattern scale or spacing (pattern fill only):
"pattern_scale": DXFAttr(
41,
default=1,
validator=validator.is_not_zero,
fixer=RETURN_DEFAULT,
),
# Hatch pattern double flag (pattern fill only):
# 0 = not double
# 1 = double
"pattern_double": DXFAttr(
77,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# 78: Number of pattern definition lines
# following: Pattern line data. Repeats number of times specified by
# code 78. See Pattern Data
# Pixel size used to determine the density to perform various intersection
# and ray casting operations in hatch pattern computation for associative
# hatches and hatches created with the Flood method of hatching
"pixel_size": DXFAttr(47, optional=True),
# Number of seed points
"n_seed_points": DXFAttr(
98,
default=0,
validator=validator.is_greater_or_equal_zero,
fixer=RETURN_DEFAULT,
),
# 10, 20: Seed point (in OCS) 2D point (multiple entries)
# 450 Indicates solid hatch or gradient; if solid hatch, the values for the
# remaining codes are ignored but must be present. Optional;
#
# if code 450 is in the file, then the following codes must be in the
# file: 451, 452, 453, 460, 461, 462, and 470.
# If code 450 is not in the file, then the following codes must not be
# in the file: 451, 452, 453, 460, 461, 462, and 470
#
# 0 = Solid hatch
# 1 = Gradient
#
# 451 Zero is reserved for future use
# 452 Records how colors were defined and is used only by dialog code:
#
# 0 = Two-color gradient
# 1 = Single-color gradient
#
# 453 Number of colors:
#
# 0 = Solid hatch
# 2 = Gradient
#
# 460 Rotation angle in radians for gradients (default = 0, 0)
# 461 Gradient definition; corresponds to the Centered option on the
# Gradient Tab of the Boundary Hatch and Fill dialog box. Each gradient
# has two definitions, shifted and non-shifted. A Shift value describes
# the blend of the two definitions that should be used. A value of 0.0
# means only the non-shifted version should be used, and a value of 1.0
# means that only the shifted version should be used.
#
# 462 Color tint value used by dialog code (default = 0, 0; range is 0.0 to
# 1.0). The color tint value is a gradient color and controls the degree
# of tint in the dialog when the Hatch group code 452 is set to 1.
#
# 463 Reserved for future use:
#
# 0 = First value
# 1 = Second value
#
# 470 String (default = LINEAR)
},
)
acdb_hatch_group_code = group_code_mapping(acdb_hatch)
@register_entity
class Hatch(DXFPolygon):
"""DXF HATCH entity"""
DXFTYPE = "HATCH"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_hatch)
MIN_DXF_VERSION_FOR_EXPORT = const.DXF2000
LOAD_GROUP_CODES = acdb_hatch_group_code
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_hatch.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"elevation",
"extrusion",
"pattern_name",
"solid_fill",
"associative",
],
)
self.paths.export_dxf(tagwriter, self.dxftype())
self.dxf.export_dxf_attribs(tagwriter, ["hatch_style", "pattern_type"])
if self.pattern:
self.dxf.export_dxf_attribs(
tagwriter, ["pattern_angle", "pattern_scale", "pattern_double"]
)
self.pattern.export_dxf(tagwriter)
self.dxf.export_dxf_attribs(tagwriter, ["pixel_size"])
self.export_seeds(tagwriter)
if self.gradient and tagwriter.dxfversion > const.DXF2000:
self.gradient.export_dxf(tagwriter)
def load_seeds(self, tags: Tags) -> Tags:
try:
start_index = tags.tag_index(98)
except const.DXFValueError:
return tags
seed_data = tags.collect_consecutive_tags(
{98, 10, 20}, start=start_index
)
# Remove seed data from tags:
del tags[start_index : start_index + len(seed_data) + 1]
# Just process vertices with group code 10
self.seeds = [value for code, value in seed_data if code == 10]
return tags
def export_seeds(self, tagwriter: AbstractTagWriter):
tagwriter.write_tag2(98, len(self.seeds))
for seed in self.seeds:
tagwriter.write_vertex(10, seed[:2])
def remove_dependencies(self, other: Optional[Drawing] = None) -> None:
"""Remove all dependencies from actual document. (internal API)"""
if not self.is_alive:
return
super().remove_dependencies()
self.remove_association()
def remove_association(self):
"""Remove associated path elements."""
if self.dxf.associative:
self.dxf.associative = 0
for path in self.paths:
path.source_boundary_objects = []
def set_solid_fill(
self, color: int = 7, style: int = 1, rgb: Optional[RGB] = None
):
"""Set the solid fill mode and removes all gradient and pattern fill related
data.
Args:
color: :ref:`ACI`, (0 = BYBLOCK; 256 = BYLAYER)
style: hatch style (0 = normal; 1 = outer; 2 = ignore)
rgb: true color value as (r, g, b)-tuple - has higher priority
than `color`. True color support requires DXF R2000.
"""
# remove existing gradient and pattern fill
self.gradient = None
self.pattern = None
self.dxf.solid_fill = 1
# if true color is present, the color attribute is ignored
self.dxf.color = color
self.dxf.hatch_style = style
self.dxf.pattern_name = "SOLID"
self.dxf.pattern_type = const.HATCH_TYPE_PREDEFINED
if rgb is not None:
self.rgb = rgb
def associate(
self, path: AbstractBoundaryPath, entities: Iterable[DXFEntity]
):
"""Set association from hatch boundary `path` to DXF geometry `entities`.
A HATCH entity can be associative to a base geometry, this association
is **not** maintained nor verified by `ezdxf`, so if you modify the base
geometry the geometry of the boundary path is not updated and no
verification is done to check if the associated geometry matches
the boundary path, this opens many possibilities to create
invalid DXF files: USE WITH CARE!
"""
# I don't see this as a time critical operation, do as much checks as
# needed to avoid invalid DXF files.
if not self.is_alive:
raise const.DXFStructureError("HATCH entity is destroyed")
doc = self.doc
owner = self.dxf.owner
handle = self.dxf.handle
if doc is None or owner is None or handle is None:
raise const.DXFStructureError(
"virtual entity can not have associated entities"
)
for entity in entities:
if not entity.is_alive or entity.is_virtual:
raise const.DXFStructureError(
"associated entity is destroyed or a virtual entity"
)
if doc is not entity.doc:
raise const.DXFStructureError(
"associated entity is from a different document"
)
if owner != entity.dxf.owner:
raise const.DXFStructureError(
"associated entity is from a different layout"
)
path.source_boundary_objects.append(entity.dxf.handle)
entity.append_reactor_handle(handle)
self.dxf.associative = 1 if len(path.source_boundary_objects) else 0
def set_seed_points(self, points: Iterable[tuple[float, float]]) -> None:
"""Set seed points, `points` is an iterable of (x, y)-tuples.
I don't know why there can be more than one seed point.
All points in :ref:`OCS` (:attr:`Hatch.dxf.elevation` is the Z value)
"""
points = list(points)
if len(points) < 1:
raise const.DXFValueError(
"Argument points should be an iterable of 2D points and requires"
" at least one point."
)
self.seeds = list(points)
self.dxf.n_seed_points = len(self.seeds)
@@ -0,0 +1,122 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import SUBCLASS_MARKER
from ezdxf.math import NULLVEC, X_AXIS, Z_AXIS
from .spline import Spline, acdb_spline
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import acdb_entity
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.math import Matrix44
__all__ = ["Helix"]
acdb_helix = DefSubclass(
"AcDbHelix",
{
"major_release_number": DXFAttr(90, default=29),
"maintenance_release_number": DXFAttr(91, default=63),
"axis_base_point": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
"start_point": DXFAttr(
11,
xtype=XType.point3d,
default=X_AXIS,
fixer=RETURN_DEFAULT,
),
"axis_vector": DXFAttr(
12,
xtype=XType.point3d,
default=Z_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# base radius = (start-point - axis_base_point).magnitude
"radius": DXFAttr(40, default=1), # top radius
"turns": DXFAttr(41, default=1),
"turn_height": DXFAttr(42, default=1),
# Handedness:
# 0 = left
# 1 = right
"handedness": DXFAttr(
290,
default=1,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Constrain type:
# 0 = Constrain turn height
# 1 = Constrain turns
# 2 = Constrain height
"constrain": DXFAttr(
280,
default=1,
validator=validator.is_in_integer_range(0, 3),
fixer=RETURN_DEFAULT,
),
},
)
acdb_helix_group_codes = group_code_mapping(acdb_helix)
@register_entity
class Helix(Spline):
"""DXF HELIX entity"""
DXFTYPE = "HELIX"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_spline, acdb_helix)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_helix_group_codes, 3, recover=True
)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_helix.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"major_release_number",
"maintenance_release_number",
"axis_base_point",
"start_point",
"axis_vector",
"radius",
"turns",
"turn_height",
"handedness",
"constrain",
],
)
def transform(self, m: Matrix44) -> Helix:
"""Transform the HELIX entity by transformation matrix `m` inplace."""
super().transform(m)
self.dxf.axis_base_point = m.transform(self.dxf.axis_base_point)
self.dxf.axis_vector = m.transform_direction(self.dxf.axis_vector)
self.dxf.start_point = m.transform(self.dxf.start_point)
self.dxf.radius = m.transform_direction(
(self.dxf.radius, 0, 0)
).magnitude
self.post_transform(m)
return self
@@ -0,0 +1,139 @@
# Copyright (c) 2019-2023, Manfred Moitzi
# License: MIT-License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing_extensions import Self
from ezdxf.lldxf.const import SUBCLASS_MARKER, DXFStructureError
from ezdxf.lldxf.attributes import (
DXFAttributes,
DefSubclass,
DXFAttr,
group_code_mapping,
)
from .dxfentity import base_class, SubclassProcessor
from .dxfobj import DXFObject
from .factory import register_entity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace, DXFEntity
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.lldxf.tags import Tags
__all__ = ["IDBuffer", "FieldList", "LayerFilter"]
acdb_id_buffer = DefSubclass("AcDbIdBuffer", {})
@register_entity
class IDBuffer(DXFObject):
"""DXF IDBUFFER entity"""
DXFTYPE = "IDBUFFER"
DXFATTRIBS = DXFAttributes(base_class, acdb_id_buffer)
def __init__(self) -> None:
super().__init__()
self.handles: list[str] = []
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy handles"""
assert isinstance(entity, IDBuffer)
entity.handles = list(self.handles)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
if len(processor.subclasses) < 2:
raise DXFStructureError(
f"Missing required subclass in IDBUFFER(#{dxf.handle})"
)
self.load_handles(processor.subclasses[1])
return dxf
def load_handles(self, tags: Tags):
self.handles = [value for code, value in tags if code == 330]
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_id_buffer.name)
self.export_handles(tagwriter)
def export_handles(self, tagwriter: AbstractTagWriter):
for handle in self.handles:
tagwriter.write_tag2(330, handle)
acdb_id_set = DefSubclass(
"AcDbIdSet",
{
"flags": DXFAttr(90, default=0), # not documented by Autodesk
},
)
acdb_id_set_group_codes = group_code_mapping(acdb_id_set)
acdb_field_list = DefSubclass("AcDbFieldList", {})
@register_entity
class FieldList(IDBuffer):
"""DXF FIELDLIST entity"""
DXFTYPE = "FIELDLIST"
DXFATTRIBS = DXFAttributes(base_class, acdb_id_set, acdb_field_list)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super(DXFObject, self).load_dxf_attribs(processor)
if processor:
if len(processor.subclasses) < 3:
raise DXFStructureError(
f"Missing required subclass in FIELDLIST(#{dxf.handle})"
)
processor.fast_load_dxfattribs(dxf, acdb_id_set_group_codes, 1)
# Load field list:
self.load_handles(processor.subclasses[1])
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super(DXFObject, self).export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_id_set.name)
self.dxf.export_dxf_attribs(tagwriter, "flags")
self.export_handles(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_field_list.name)
acdb_filter = DefSubclass("AcDbFilter", {})
acdb_layer_filter = DefSubclass("AcDbLayerFilter", {})
@register_entity
class LayerFilter(IDBuffer):
"""DXF LAYER_FILTER entity"""
DXFTYPE = "LAYER_FILTER"
DXFATTRIBS = DXFAttributes(base_class, acdb_filter, acdb_layer_filter)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super(DXFObject, self).load_dxf_attribs(processor)
if processor:
if len(processor.subclasses) < 3:
raise DXFStructureError(
f"Missing required subclass in LAYER_FILTER(#{dxf.handle})"
)
# Load layer filter:
self.load_handles(processor.subclasses[2])
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super(DXFObject, self).export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_filter.name)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_layer_filter.name)
self.export_handles(tagwriter)
@@ -0,0 +1,760 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
import os
import pathlib
from typing import (
TYPE_CHECKING,
Iterable,
cast,
Optional,
Callable,
Union,
Type,
)
from typing_extensions import Self
import logging
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2000, DXF2010
from ezdxf.math import Vec3, Vec2, BoundingBox2d, UVec, Matrix44
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity
from .dxfobj import DXFObject
from .factory import register_entity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.audit import Auditor
from ezdxf.entities import DXFNamespace, DXFEntity, Dictionary
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.lldxf.types import DXFTag
from ezdxf.document import Drawing
from ezdxf import xref
__all__ = ["Image", "ImageDef", "ImageDefReactor", "RasterVariables", "Wipeout"]
logger = logging.getLogger("ezdxf")
class ImageBase(DXFGraphic):
"""DXF IMAGE entity"""
DXFTYPE = "IMAGEBASE"
_CLS_GROUP_CODES: dict[int, Union[str, list[str]]] = dict()
_SUBCLASS_NAME = "dummy"
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
SHOW_IMAGE = 1
SHOW_IMAGE_WHEN_NOT_ALIGNED = 2
USE_CLIPPING_BOUNDARY = 4
USE_TRANSPARENCY = 8
def __init__(self) -> None:
super().__init__()
# Boundary/Clipping path coordinates:
# 0/0 is in the Left/Top corner of the image!
# x-coordinates increases in u_pixel vector direction
# y-coordinates increases against the v_pixel vector!
# see also WCS coordinate calculation
self._boundary_path: list[Vec2] = []
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, ImageBase)
entity._boundary_path = list(self._boundary_path)
def post_new_hook(self) -> None:
super().post_new_hook()
self.reset_boundary_path()
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
path_tags = processor.subclasses[2].pop_tags(codes=(14,))
self.load_boundary_path(path_tags)
processor.fast_load_dxfattribs(dxf, self._CLS_GROUP_CODES, 2, recover=True)
if len(self.boundary_path) < 2: # something is wrong
self.dxf = dxf
self.reset_boundary_path()
return dxf
def load_boundary_path(self, tags: Iterable[DXFTag]):
self._boundary_path = [Vec2(value) for code, value in tags if code == 14]
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, self._SUBCLASS_NAME)
self.dxf.count_boundary_points = len(self.boundary_path)
self.dxf.export_dxf_attribs(
tagwriter,
[
"class_version",
"insert",
"u_pixel",
"v_pixel",
"image_size",
"image_def_handle",
"flags",
"clipping",
"brightness",
"contrast",
"fade",
"image_def_reactor_handle",
"clipping_boundary_type",
"count_boundary_points",
],
)
self.export_boundary_path(tagwriter)
if tagwriter.dxfversion >= DXF2010:
self.dxf.export_dxf_attribs(tagwriter, "clip_mode")
def export_boundary_path(self, tagwriter: AbstractTagWriter):
for vertex in self.boundary_path:
tagwriter.write_vertex(14, vertex)
@property
def boundary_path(self):
"""Returns the boundray path in raw form in pixel coordinates.
A list of vertices as pixel coordinates, Two vertices describe a
rectangle, lower left corner is (-0.5, -0.5) and upper right corner
is (ImageSizeX-0.5, ImageSizeY-0.5), more than two vertices is a
polygon as clipping path. All vertices as pixel coordinates. (read/write)
"""
return self._boundary_path
@boundary_path.setter
def boundary_path(self, vertices: Iterable[UVec]) -> None:
self.set_boundary_path(vertices)
def set_boundary_path(self, vertices: Iterable[UVec]) -> None:
"""Set boundary path to `vertices`. Two vertices describe a rectangle
(lower left and upper right corner), more than two vertices is a polygon
as clipping path.
"""
_vertices = Vec2.list(vertices)
if len(_vertices):
if len(_vertices) > 2 and not _vertices[-1].isclose(_vertices[0]):
# Close path, otherwise AutoCAD crashes
_vertices.append(_vertices[0])
self._boundary_path = _vertices
self.set_flag_state(self.USE_CLIPPING_BOUNDARY, state=True)
self.dxf.clipping = 1
self.dxf.clipping_boundary_type = 1 if len(_vertices) < 3 else 2
self.dxf.count_boundary_points = len(self._boundary_path)
else:
self.reset_boundary_path()
def reset_boundary_path(self) -> None:
"""Reset boundary path to the default rectangle [(-0.5, -0.5),
(ImageSizeX-0.5, ImageSizeY-0.5)].
"""
lower_left_corner = Vec2(-0.5, -0.5)
upper_right_corner = Vec2(self.dxf.image_size) + lower_left_corner
self._boundary_path = [lower_left_corner, upper_right_corner]
self.set_flag_state(Image.USE_CLIPPING_BOUNDARY, state=False)
self.dxf.clipping = 0
self.dxf.clipping_boundary_type = 1
self.dxf.count_boundary_points = 2
def transform(self, m: Matrix44) -> Self:
"""Transform IMAGE entity by transformation matrix `m` inplace."""
self.dxf.insert = m.transform(self.dxf.insert)
self.dxf.u_pixel = m.transform_direction(self.dxf.u_pixel)
self.dxf.v_pixel = m.transform_direction(self.dxf.v_pixel)
self.post_transform(m)
return self
def get_wcs_transform(self) -> Matrix44:
m = Matrix44()
m.set_row(0, Vec3(self.dxf.u_pixel))
m.set_row(1, Vec3(self.dxf.v_pixel))
m.set_row(3, Vec3(self.dxf.insert))
return m
def pixel_boundary_path(self) -> list[Vec2]:
"""Returns the boundary path as closed loop in pixel coordinates. Resolves the
simple form of two vertices as a rectangle. The image coordinate system has an
inverted y-axis and the top-left corner is (0, 0).
.. versionchanged:: 1.2.0
renamed from :meth:`boundray_path_ocs()`
"""
boundary_path = self.boundary_path
if len(boundary_path) == 2: # rectangle
p0, p1 = boundary_path
boundary_path = [p0, Vec2(p1.x, p0.y), p1, Vec2(p0.x, p1.y)]
if not boundary_path[0].isclose(boundary_path[-1]):
boundary_path.append(boundary_path[0])
return boundary_path
def boundary_path_wcs(self) -> list[Vec3]:
"""Returns the boundary/clipping path in WCS coordinates.
It's recommended to acquire the clipping path as :class:`~ezdxf.path.Path` object
by the :func:`~ezdxf.path.make_path` function::
from ezdxf.path import make_path
image = ... # get image entity
clipping_path = make_path(image)
"""
u = Vec3(self.dxf.u_pixel)
v = Vec3(self.dxf.v_pixel)
origin = Vec3(self.dxf.insert)
origin += u * 0.5 - v * 0.5
height = self.dxf.image_size.y
# Boundary/Clipping path origin 0/0 is in the Left/Top corner of the image!
vertices = [
origin + (u * p.x) + (v * (height - p.y))
for p in self.pixel_boundary_path()
]
return vertices
def destroy(self) -> None:
if not self.is_alive:
return
del self._boundary_path
super().destroy()
acdb_image = DefSubclass(
"AcDbRasterImage",
{
"class_version": DXFAttr(90, dxfversion=DXF2000, default=0),
"insert": DXFAttr(10, xtype=XType.point3d),
# U-vector of a single pixel (points along the visual bottom of the image,
# starting at the insertion point)
"u_pixel": DXFAttr(11, xtype=XType.point3d),
# V-vector of a single pixel (points along the visual left side of the
# image, starting at the insertion point)
"v_pixel": DXFAttr(12, xtype=XType.point3d),
# Image size in pixels
"image_size": DXFAttr(13, xtype=XType.point2d),
# Hard reference to image def object
"image_def_handle": DXFAttr(340),
# Image display properties:
# 1 = Show image
# 2 = Show image when not aligned with screen
# 4 = Use clipping boundary
# 8 = Transparency is on
"flags": DXFAttr(70, default=3),
# Clipping state:
# 0 = Off
# 1 = On
"clipping": DXFAttr(
280,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Brightness value (0-100; default = 50)
"brightness": DXFAttr(
281,
default=50,
validator=validator.is_in_integer_range(0, 101),
fixer=validator.fit_into_integer_range(0, 101),
),
# Contrast value (0-100; default = 50)
"contrast": DXFAttr(
282,
default=50,
validator=validator.is_in_integer_range(0, 101),
fixer=validator.fit_into_integer_range(0, 101),
),
# Fade value (0-100; default = 0)
"fade": DXFAttr(
283,
default=0,
validator=validator.is_in_integer_range(0, 101),
fixer=validator.fit_into_integer_range(0, 101),
),
# Hard reference to image def reactor object, not required by AutoCAD
"image_def_reactor_handle": DXFAttr(360),
# Clipping boundary type:
# 1 = Rectangular
# 2 = Polygonal
"clipping_boundary_type": DXFAttr(
71,
default=1,
validator=validator.is_one_of({1, 2}),
fixer=RETURN_DEFAULT,
),
# Number of clip boundary vertices that follow
"count_boundary_points": DXFAttr(91),
# Clip mode:
# 0 = outside
# 1 = inside mode
"clip_mode": DXFAttr(
290,
dxfversion=DXF2010,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# boundary path coordinates are pixel coordinates NOT drawing units
},
)
acdb_image_group_codes = group_code_mapping(acdb_image)
@register_entity
class Image(ImageBase):
"""DXF IMAGE entity"""
DXFTYPE = "IMAGE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_image)
_CLS_GROUP_CODES = acdb_image_group_codes
_SUBCLASS_NAME = acdb_image.name # type: ignore
DEFAULT_ATTRIBS = {"layer": "0", "flags": 3}
def __init__(self) -> None:
super().__init__()
self._boundary_path: list[Vec2] = []
self._image_def: Optional[ImageDef] = None
self._image_def_reactor: Optional[ImageDefReactor] = None
@classmethod
def new(
cls: Type[Image],
handle: Optional[str] = None,
owner: Optional[str] = None,
dxfattribs: Optional[dict] = None,
doc: Optional[Drawing] = None,
) -> Image:
dxfattribs = dxfattribs or {}
# 'image_def' is not a real DXF attribute (image_def_handle)
image_def = dxfattribs.pop("image_def", None)
image_size = (1, 1)
if image_def and image_def.is_alive:
image_size = image_def.dxf.image_size
dxfattribs.setdefault("image_size", image_size)
image = cast("Image", super().new(handle, owner, dxfattribs, doc))
image.image_def = image_def
return image
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, Image)
super().copy_data(entity, copy_strategy=copy_strategy)
# Each IMAGE has its own ImageDefReactor object, which will be created by
# binding the copy to the document.
entity.dxf.discard("image_def_reactor_handle")
entity._image_def_reactor = None
# shared IMAGE_DEF
entity._image_def = self._image_def
def post_bind_hook(self) -> None:
# Document in LOAD process -> post_load_hook()
if self.doc.is_loading: # type: ignore
return
if self._image_def_reactor: # ImageDefReactor already exist
return
# The new Image was created by ezdxf and the ImageDefReactor
# object does not exist:
self._create_image_def_reactor()
def post_load_hook(self, doc: Drawing) -> Optional[Callable]:
super().post_load_hook(doc)
db = doc.entitydb
self._image_def = db.get(self.dxf.get("image_def_handle", None)) # type: ignore
if self._image_def is None:
# unrecoverable structure error
self.destroy()
return None
self._image_def_reactor = db.get( # type: ignore
self.dxf.get("image_def_reactor_handle", None)
)
if self._image_def_reactor is None:
# Image and ImageDef exist - this is recoverable by creating
# an ImageDefReactor, but the objects section does not exist yet!
# Return a post init command:
return self._fix_missing_image_def_reactor
return None
def _fix_missing_image_def_reactor(self):
try:
self._create_image_def_reactor()
except Exception as e:
logger.exception(
f"An exception occurred while executing fixing command for "
f"{str(self)}, destroying entity.",
exc_info=e,
)
self.destroy()
return
logger.debug(f"Created missing ImageDefReactor for {str(self)}")
def _create_image_def_reactor(self):
# ImageDef -> ImageDefReactor -> Image
image_def_reactor = self.doc.objects.add_image_def_reactor(self.dxf.handle)
reactor_handle = image_def_reactor.dxf.handle
# Link Image to ImageDefReactor:
self.dxf.image_def_reactor_handle = reactor_handle
self._image_def_reactor = image_def_reactor
# Link ImageDef to ImageDefReactor if in same document (XREF mapping!):
if self.doc is self._image_def.doc:
self._image_def.append_reactor_handle(reactor_handle)
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
super().register_resources(registry)
if isinstance(self.image_def, ImageDef):
registry.add_handle(self.image_def.dxf.handle)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
assert isinstance(clone, Image)
super().map_resources(clone, mapping)
source_image_def = self.image_def
if isinstance(source_image_def, ImageDef):
name = self.get_image_def_name()
name, clone_image_def = mapping.map_acad_dict_entry(
"ACAD_IMAGE_DICT", name, source_image_def
)
if isinstance(clone_image_def, ImageDef):
clone.image_def = clone_image_def
if isinstance(clone._image_def_reactor, ImageDefReactor):
clone_image_def.append_reactor_handle(
clone._image_def_reactor.dxf.handle
)
# Note:
# The IMAGEDEF_REACTOR was created automatically at binding the copy to
# a new document, but the handle of the IMAGEDEF_REACTOR was not add to the
# IMAGEDEF reactor handles, because at this point the IMAGE had still a reference
# to the IMAGEDEF of the source document.
def get_image_def_name(self) -> str:
"""Returns the name of the `image_def` entry in the ACAD_IMAGE_DICT."""
if self.doc is None:
return ""
image_dict = self.doc.rootdict.get_required_dict("ACAD_IMAGE_DICT")
for name, entry in image_dict.items():
if entry is self._image_def:
return name
return ""
@property
def image_def(self) -> Optional[ImageDef]:
"""Returns the associated IMAGEDEF entity, see :class:`ImageDef`."""
return self._image_def
@image_def.setter
def image_def(self, image_def: ImageDef) -> None:
if image_def and image_def.is_alive:
self.dxf.image_def_handle = image_def.dxf.handle
self._image_def = image_def
else:
self.dxf.discard("image_def_handle")
self._image_def = None
@property
def image_def_reactor(self) -> Optional[ImageDefReactor]:
"""Returns the associated IMAGEDEF_REACTOR entity."""
return self._image_def_reactor
def destroy(self) -> None:
if not self.is_alive:
return
reactor = self._image_def_reactor
if reactor and reactor.is_alive:
image_def = self.image_def
if image_def and image_def.is_alive:
image_def.discard_reactor_handle(reactor.dxf.handle)
reactor.destroy()
super().destroy()
def audit(self, auditor: Auditor) -> None:
super().audit(auditor)
# DXF reference error: Subclass marker (AcDbRasterImage)
acdb_wipeout = DefSubclass("AcDbWipeout", dict(acdb_image.attribs))
acdb_wipeout_group_codes = group_code_mapping(acdb_wipeout)
@register_entity
class Wipeout(ImageBase):
"""DXF WIPEOUT entity"""
DXFTYPE = "WIPEOUT"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_wipeout)
DEFAULT_ATTRIBS = {
"layer": "0",
"flags": 7,
"clipping": 1,
"brightness": 50,
"contrast": 50,
"fade": 0,
"image_size": (1, 1),
"image_def_handle": "0", # has no ImageDef()
"image_def_reactor_handle": "0", # has no ImageDefReactor()
"clip_mode": 0,
}
_CLS_GROUP_CODES = acdb_wipeout_group_codes
_SUBCLASS_NAME = acdb_wipeout.name # type: ignore
def set_masking_area(self, vertices: Iterable[UVec]) -> None:
"""Set a new masking area, the area is placed in the layout xy-plane."""
self.update_dxf_attribs(self.DEFAULT_ATTRIBS)
vertices = Vec2.list(vertices)
bounds = BoundingBox2d(vertices)
x_size, y_size = bounds.size
dxf = self.dxf
dxf.insert = Vec3(bounds.extmin)
dxf.u_pixel = Vec3(x_size, 0, 0)
dxf.v_pixel = Vec3(0, y_size, 0)
def boundary_path():
extmin = bounds.extmin
for vertex in vertices:
v = vertex - extmin
yield Vec2(v.x / x_size - 0.5, 0.5 - v.y / y_size)
self.set_boundary_path(boundary_path())
def _reset_handles(self):
self.dxf.image_def_reactor_handle = "0"
self.dxf.image_def_handle = "0"
def audit(self, auditor: Auditor) -> None:
self._reset_handles()
super().audit(auditor)
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
self._reset_handles()
super().export_entity(tagwriter)
# About Image File Paths:
# See notes in knowledge graph: [[IMAGE File Paths]]
# https://ezdxf.mozman.at/notes/#/page/image%20file%20paths
acdb_image_def = DefSubclass(
"AcDbRasterImageDef",
{
"class_version": DXFAttr(90, default=0),
# File name of image:
"filename": DXFAttr(1),
# Image size in pixels:
"image_size": DXFAttr(10, xtype=XType.point2d),
# Default size of one pixel in AutoCAD units:
"pixel_size": DXFAttr(11, xtype=XType.point2d, default=Vec2(0.01, 0.01)),
"loaded": DXFAttr(280, default=1),
# Resolution units - this enums differ from the usual drawing units,
# units.py, same as for RasterVariables.dxf.units, but only these 3 values
# are valid - confirmed by ODA Specs 20.4.81 IMAGEDEF:
# 0 = No units
# 2 = Centimeters
# 5 = Inch
"resolution_units": DXFAttr(
281,
default=0,
validator=validator.is_one_of({0, 2, 5}),
fixer=RETURN_DEFAULT,
),
},
)
acdb_image_def_group_codes = group_code_mapping(acdb_image_def)
@register_entity
class ImageDef(DXFObject):
"""DXF IMAGEDEF entity"""
DXFTYPE = "IMAGEDEF"
DXFATTRIBS = DXFAttributes(base_class, acdb_image_def)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_image_def_group_codes, 1)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_image_def.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"class_version",
"filename",
"image_size",
"pixel_size",
"loaded",
"resolution_units",
],
)
acdb_image_def_reactor = DefSubclass(
"AcDbRasterImageDefReactor",
{
"class_version": DXFAttr(90, default=2),
# Handle to image:
"image_handle": DXFAttr(330),
},
)
acdb_image_def_reactor_group_codes = group_code_mapping(acdb_image_def_reactor)
@register_entity
class ImageDefReactor(DXFObject):
"""DXF IMAGEDEF_REACTOR entity"""
DXFTYPE = "IMAGEDEF_REACTOR"
DXFATTRIBS = DXFAttributes(base_class, acdb_image_def_reactor)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_image_def_reactor_group_codes, 1)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_image_def_reactor.name)
tagwriter.write_tag2(90, self.dxf.class_version)
tagwriter.write_tag2(330, self.dxf.image_handle)
acdb_raster_variables = DefSubclass(
"AcDbRasterVariables",
{
"class_version": DXFAttr(90, default=0),
# Frame:
# 0 = no frame
# 1 = show frame
"frame": DXFAttr(
70,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Quality:
# 0 = draft
# 1 = high
"quality": DXFAttr(
71,
default=1,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Units:
# 0 = None
# 1 = mm
# 2 = cm
# 3 = m
# 4 = km
# 5 = in
# 6 = ft
# 7 = yd
# 8 = mi
"units": DXFAttr(
72,
default=3,
validator=validator.is_in_integer_range(0, 9),
fixer=RETURN_DEFAULT,
),
},
)
acdb_raster_variables_group_codes = group_code_mapping(acdb_raster_variables)
@register_entity
class RasterVariables(DXFObject):
"""DXF RASTERVARIABLES entity"""
DXFTYPE = "RASTERVARIABLES"
DXFATTRIBS = DXFAttributes(base_class, acdb_raster_variables)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_raster_variables_group_codes, 1)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_raster_variables.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"class_version",
"frame",
"quality",
"units",
],
)
acdb_wipeout_variables = DefSubclass(
"AcDbWipeoutVariables",
{
# Display-image-frame flag:
# 0 = No frame
# 1 = Display frame
"frame": DXFAttr(
70,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
},
)
acdb_wipeout_variables_group_codes = group_code_mapping(acdb_wipeout_variables)
@register_entity
class WipeoutVariables(DXFObject):
"""DXF WIPEOUTVARIABLES entity"""
DXFTYPE = "WIPEOUTVARIABLES"
DXFATTRIBS = DXFAttributes(base_class, acdb_wipeout_variables)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_wipeout_variables_group_codes, 1)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_wipeout_variables.name)
self.dxf.export_dxf_attribs(tagwriter, "frame")
@@ -0,0 +1,790 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Iterable,
Iterator,
cast,
Union,
Optional,
Callable,
)
from typing_extensions import Self
import math
import logging
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
merge_group_code_mappings,
)
from ezdxf.lldxf.const import (
DXF12,
SUBCLASS_MARKER,
DXFValueError,
DXFKeyError,
DXFStructureError,
)
from ezdxf.math import (
Vec3,
UVec,
X_AXIS,
Y_AXIS,
Z_AXIS,
Matrix44,
UCS,
NULLVEC,
)
from ezdxf.math.transformtools import (
InsertTransformationError,
InsertCoordinateSystem,
)
from ezdxf.explode import (
explode_block_reference,
virtual_block_reference_entities,
)
from ezdxf.entities import factory
from ezdxf.query import EntityQuery
from ezdxf.audit import AuditError
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import (
DXFGraphic,
acdb_entity,
elevation_to_z_axis,
acdb_entity_group_codes,
)
from .subentity import LinkedEntities
from .attrib import Attrib
if TYPE_CHECKING:
from ezdxf.audit import Auditor
from ezdxf.entities import DXFNamespace, AttDef, DXFEntity
from ezdxf.layouts import BaseLayout, BlockLayout
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf import xref
__all__ = ["Insert"]
logger = logging.getLogger("ezdxf")
ABS_TOL = 1e-9
# DXF files as XREF:
# The INSERT entity is used to attach XREFS.
# The model space is the block content, if the whole document is used as
# BLOCK (XREF), but this is only supported for the DWG format.
# AutoCAD does not support DXF files as XREFS, they are ignored, but the DXF
# file is valid! BricsCAD shows DXF files as XREFS, but does not allow to attach
# DXF files as XREFS by the application itself.
# Multi-INSERT has subclass id AcDbMInsertBlock
acdb_block_reference = DefSubclass(
"AcDbBlockReference",
{
"attribs_follow": DXFAttr(66, default=0, optional=True),
"name": DXFAttr(2, validator=validator.is_valid_block_name),
"insert": DXFAttr(10, xtype=XType.any_point),
# Elevation is a legacy feature from R11 and prior, do not use this
# attribute, store the entity elevation in the z-axis of the vertices.
# ezdxf does not export the elevation attribute!
"elevation": DXFAttr(38, default=0, optional=True),
"xscale": DXFAttr(
41,
default=1,
optional=True,
validator=validator.is_not_zero,
fixer=RETURN_DEFAULT,
),
"yscale": DXFAttr(
42,
default=1,
optional=True,
validator=validator.is_not_zero,
fixer=RETURN_DEFAULT,
),
"zscale": DXFAttr(
43,
default=1,
optional=True,
validator=validator.is_not_zero,
fixer=RETURN_DEFAULT,
),
"rotation": DXFAttr(50, default=0, optional=True),
"column_count": DXFAttr(
70,
default=1,
optional=True,
validator=validator.is_greater_zero,
fixer=RETURN_DEFAULT,
),
"row_count": DXFAttr(
71,
default=1,
optional=True,
validator=validator.is_greater_zero,
fixer=RETURN_DEFAULT,
),
"column_spacing": DXFAttr(44, default=0, optional=True),
"row_spacing": DXFAttr(45, default=0, optional=True),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
},
)
acdb_block_reference_group_codes = group_code_mapping(acdb_block_reference)
merged_insert_group_codes = merge_group_code_mappings(
acdb_entity_group_codes, acdb_block_reference_group_codes # type: ignore
)
# Notes to SEQEND:
#
# The INSERT entity requires only a SEQEND if ATTRIB entities are attached.
# So a loaded INSERT could have a missing SEQEND.
#
# A bounded INSERT needs a SEQEND to be valid at export if there are attached
# ATTRIB entities, but the LinkedEntities.post_bind_hook() method creates
# always a new SEQEND after binding the INSERT entity to a document.
#
# Nonetheless, the Insert.add_attrib() method also creates the required SEQEND entity if
# necessary.
@factory.register_entity
class Insert(LinkedEntities):
"""DXF INSERT entity
The INSERT entity is hard owner of its ATTRIB entities and the SEQEND entity:
ATTRIB.dxf.owner == INSERT.dxf.handle
SEQEND.dxf.owner == INSERT.dxf.handle
Note:
The ATTDEF entity in block definitions is owned by the BLOCK_RECORD like
all graphical entities.
"""
DXFTYPE = "INSERT"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_block_reference)
@property
def attribs(self) -> list[Attrib]:
return self._sub_entities # type: ignore
@property
def attribs_follow(self) -> bool:
return bool(len(self.attribs))
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, merged_insert_group_codes)
if processor.r12:
# Transform elevation attribute from R11 to z-axis values:
elevation_to_z_axis(dxf, ("insert",))
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
if (self.dxf.column_count > 1) or (self.dxf.row_count > 1):
tagwriter.write_tag2(SUBCLASS_MARKER, "AcDbMInsertBlock")
else:
tagwriter.write_tag2(SUBCLASS_MARKER, "AcDbBlockReference")
if self.attribs_follow:
tagwriter.write_tag2(66, 1)
self.dxf.export_dxf_attribs(
tagwriter,
[
"name",
"insert",
"xscale",
"yscale",
"zscale",
"rotation",
"column_count",
"row_count",
"column_spacing",
"row_spacing",
"extrusion",
],
)
def export_dxf(self, tagwriter: AbstractTagWriter):
super().export_dxf(tagwriter)
# Do no export SEQEND if no ATTRIBS attached:
if self.attribs_follow:
self.process_sub_entities(lambda e: e.export_dxf(tagwriter))
def register_resources(self, registry: xref.Registry) -> None:
# The attached ATTRIB entities are registered by the parent class LinkedEntities
super().register_resources(registry)
registry.add_block_name(self.dxf.name)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
# The attached ATTRIB entities are mapped by the parent class LinkedEntities
super().map_resources(clone, mapping)
clone.dxf.name = mapping.get_block_name(self.dxf.name)
@property
def has_scaling(self) -> bool:
"""Returns ``True`` if scaling is applied to any axis."""
if self.dxf.hasattr("xscale") and self.dxf.xscale != 1:
return True
if self.dxf.hasattr("yscale") and self.dxf.yscale != 1:
return True
if self.dxf.hasattr("zscale") and self.dxf.zscale != 1:
return True
return False
@property
def has_uniform_scaling(self) -> bool:
"""Returns ``True`` if the scale factor is uniform for x-, y- and z-axis,
ignoring reflections e.g. (1, 1, -1) is uniform scaling.
"""
return abs(self.dxf.xscale) == abs(self.dxf.yscale) == abs(self.dxf.zscale)
def set_scale(self, factor: float):
"""Set a uniform scale factor."""
if factor == 0:
raise ValueError("Invalid scale factor.")
self.dxf.xscale = factor
self.dxf.yscale = factor
self.dxf.zscale = factor
return self
def block(self) -> Optional[BlockLayout]:
"""Returns the associated :class:`~ezdxf.layouts.BlockLayout`."""
if self.doc:
return self.doc.blocks.get(self.dxf.name)
return None
def is_xref(self) -> bool:
"""Return ``True`` if the INSERT entity represents a XREF or XREF_OVERLAY."""
block = self.block()
if block is not None:
return block.block_record.is_xref
return False
def place(
self,
insert: Optional[UVec] = None,
scale: Optional[tuple[float, float, float]] = None,
rotation: Optional[float] = None,
) -> Insert:
"""
Set the location, scaling and rotation attributes. Arguments which are ``None``
will be ignored.
Args:
insert: insert location as (x, y [,z]) tuple
scale: (x-scale, y-scale, z-scale) tuple
rotation : rotation angle in degrees
"""
if insert is not None:
self.dxf.insert = insert
if scale is not None:
if len(scale) != 3:
raise DXFValueError("Argument scale has to be a (x, y[, z]) tuple.")
x, y, z = scale
self.dxf.xscale = x
self.dxf.yscale = y
self.dxf.zscale = z
if rotation is not None:
self.dxf.rotation = rotation
return self
def grid(
self, size: tuple[int, int] = (1, 1), spacing: tuple[float, float] = (1, 1)
) -> Insert:
"""Place block reference in a grid layout, grid `size` defines the
row- and column count, `spacing` defines the distance between two block
references.
Args:
size: grid size as (row_count, column_count) tuple
spacing: distance between placing as (row_spacing, column_spacing) tuple
"""
try:
rows, cols = size
except ValueError:
raise DXFValueError("Size has to be a (row_count, column_count) tuple.")
self.dxf.row_count = rows
self.dxf.column_count = cols
try:
row_spacing, col_spacing = spacing
except ValueError:
raise DXFValueError(
"Spacing has to be a (row_spacing, column_spacing) tuple."
)
self.dxf.row_spacing = row_spacing
self.dxf.column_spacing = col_spacing
return self
def get_attrib(
self, tag: str, search_const: bool = False
) -> Optional[Union[Attrib, AttDef]]:
"""Get an attached :class:`Attrib` entity with the given `tag`,
returns ``None`` if not found. Some applications do not attach constant
ATTRIB entities, set `search_const` to ``True``, to get at least the
associated :class:`AttDef` entity.
Args:
tag: tag name of the ATTRIB entity
search_const: search also const ATTDEF entities
"""
for attrib in self.attribs:
if tag == attrib.dxf.tag:
return attrib
if search_const and self.doc is not None:
block = self.doc.blocks[self.dxf.name]
for attdef in block.get_const_attdefs():
if tag == attdef.dxf.tag:
return attdef
return None
def get_attrib_text(
self, tag: str, default: str = "", search_const: bool = False
) -> str:
"""Get content text of an attached :class:`Attrib` entity with
the given `tag`, returns the `default` value if not found.
Some applications do not attach constant ATTRIB entities, set
`search_const` to ``True``, to get content text of the
associated :class:`AttDef` entity.
Args:
tag: tag name of the ATTRIB entity
default: default value if ATTRIB `tag` is absent
search_const: search also const ATTDEF entities
"""
attrib = self.get_attrib(tag, search_const)
if attrib is None:
return default
return attrib.dxf.text
def has_attrib(self, tag: str, search_const: bool = False) -> bool:
"""Returns ``True`` if the INSERT entity has an attached ATTRIB entity with the
given `tag`. Some applications do not attach constant ATTRIB entities, set
`search_const` to ``True``, to check for an associated :class:`AttDef` entity
with constant content.
Args:
tag: tag name fo the ATTRIB entity
search_const: search also const ATTDEF entities
"""
return self.get_attrib(tag, search_const) is not None
def add_attrib(
self, tag: str, text: str, insert: UVec = (0, 0), dxfattribs=None
) -> Attrib:
"""Attach an :class:`Attrib` entity to the block reference.
Example for appending an attribute to an INSERT entity::
e.add_attrib('EXAMPLETAG', 'example text').set_placement(
(3, 7), align=TextEntityAlignment.MIDDLE_CENTER
)
Args:
tag: tag name of the ATTRIB entity
text: content text as string
insert: insert location as (x, y[, z]) tuple in :ref:`OCS`
dxfattribs: additional DXF attributes for the ATTRIB entity
"""
dxfattribs = dict(dxfattribs or {})
dxfattribs["tag"] = tag
dxfattribs["text"] = text
dxfattribs["insert"] = insert
attrib = cast("Attrib", self._new_compound_entity("ATTRIB", dxfattribs))
self.attribs.append(attrib)
# This case is only possible if the INSERT was read from a file without
# attached ATTRIB entities:
if self.seqend is None:
self.new_seqend()
return attrib
def delete_attrib(self, tag: str, ignore=False) -> None:
"""Delete an attached :class:`Attrib` entity from INSERT. Raises an
:class:`DXFKeyError` exception, if no ATTRIB for the given `tag` exist if
`ignore` is ``False``.
Args:
tag: tag name of the ATTRIB entity
ignore: ``False`` for raising :class:`DXFKeyError` if ATTRIB `tag`
does not exist.
Raises:
DXFKeyError: no ATTRIB for the given `tag` exist
"""
for index, attrib in enumerate(self.attribs):
if attrib.dxf.tag == tag:
del self.attribs[index]
attrib.destroy()
return
if not ignore:
raise DXFKeyError(tag)
def delete_all_attribs(self) -> None:
"""Delete all :class:`Attrib` entities attached to the INSERT entity."""
if not self.is_alive:
return
for attrib in self.attribs:
attrib.destroy()
self._sub_entities = []
def transform(self, m: Matrix44) -> Insert:
"""Transform INSERT entity by transformation matrix `m` inplace.
Unlike the transformation matrix `m`, the INSERT entity can not
represent a non-orthogonal target coordinate system and an
:class:`InsertTransformationError` will be raised in that case.
"""
dxf = self.dxf
source_system = InsertCoordinateSystem(
insert=Vec3(dxf.insert),
scale=(dxf.xscale, dxf.yscale, dxf.zscale),
rotation=dxf.rotation,
extrusion=dxf.extrusion,
)
try:
target_system = source_system.transform(m, ABS_TOL)
except InsertTransformationError:
raise InsertTransformationError(
"INSERT entity can not represent a non-orthogonal target coordinate system."
)
dxf.insert = target_system.insert
dxf.rotation = target_system.rotation
dxf.extrusion = target_system.extrusion
dxf.xscale = target_system.scale_factor_x
dxf.yscale = target_system.scale_factor_y
dxf.zscale = target_system.scale_factor_z
for attrib in self.attribs:
attrib.transform(m)
self.post_transform(m)
return self
def translate(self, dx: float, dy: float, dz: float) -> Insert:
"""Optimized INSERT translation about `dx` in x-axis, `dy` in y-axis
and `dz` in z-axis.
"""
ocs = self.ocs()
self.dxf.insert = ocs.from_wcs(Vec3(dx, dy, dz) + ocs.to_wcs(self.dxf.insert))
for attrib in self.attribs:
attrib.translate(dx, dy, dz)
return self
def matrix44(self) -> Matrix44:
"""Returns a transformation matrix to transform the block entities from the
block reference coordinate system into the :ref:`WCS`.
"""
dxf = self.dxf
sx = dxf.xscale
sy = dxf.yscale
sz = dxf.zscale
ocs = self.ocs()
extrusion = ocs.uz
ux = Vec3(ocs.to_wcs(X_AXIS))
uy = Vec3(ocs.to_wcs(Y_AXIS))
m = Matrix44.ucs(ux=ux * sx, uy=uy * sy, uz=extrusion * sz)
# same as Matrix44.ucs(ux, uy, extrusion) * Matrix44.scale(sx, sy, sz)
angle = math.radians(dxf.rotation)
if angle:
m *= Matrix44.axis_rotate(extrusion, angle)
insert = ocs.to_wcs(dxf.get("insert", NULLVEC))
block_layout = self.block()
if block_layout is not None:
# transform block base point into WCS without translation
insert -= m.transform_direction(block_layout.block.dxf.base_point) # type: ignore
# set translation
m.set_row(3, insert.xyz)
return m
def ucs(self):
"""Returns the block reference coordinate system as :class:`ezdxf.math.UCS`
object.
"""
m = self.matrix44()
ucs = UCS()
ucs.matrix = m
return ucs
def reset_transformation(self) -> None:
"""Reset block reference attributes location, rotation angle and
the extrusion vector but preserves the scale factors.
"""
self.dxf.insert = NULLVEC
self.dxf.discard("rotation")
self.dxf.discard("extrusion")
def explode(
self, target_layout: Optional[BaseLayout] = None, *, redraw_order=False
) -> EntityQuery:
"""Explodes the block reference entities into the target layout, if target
layout is ``None``, the layout of the block reference will be used.
This method destroys the source block reference entity.
Transforms the block entities into the required :ref:`WCS` location by
applying the block reference attributes `insert`, `extrusion`,
`rotation` and the scale factors `xscale`, `yscale` and `zscale`.
Attached ATTRIB entities are converted to TEXT entities, this is the
behavior of the BURST command of the AutoCAD Express Tools.
.. warning::
**Non-uniform scale factors** may lead to incorrect results some entities
(TEXT, MTEXT, ATTRIB).
Args:
target_layout: target layout for exploded entities, ``None`` for
same layout as source entity.
redraw_order: create entities in ascending redraw order if ``True``
Returns:
:class:`~ezdxf.query.EntityQuery` container referencing all exploded
DXF entities.
"""
if target_layout is None:
target_layout = self.get_layout()
if target_layout is None:
raise DXFStructureError(
"INSERT without layout assignment, specify target layout"
)
return explode_block_reference(
self, target_layout=target_layout, redraw_order=redraw_order
)
def __virtual_entities__(self) -> Iterator[DXFGraphic]:
"""Implements the SupportsVirtualEntities protocol.
This protocol is for consistent internal usage and does not replace
the method :meth:`virtual_entities`! Ignores the redraw-order!
"""
return self.virtual_entities()
def virtual_entities(
self,
*,
skipped_entity_callback: Optional[Callable[[DXFGraphic, str], None]] = None,
redraw_order=False,
) -> Iterator[DXFGraphic]:
"""
Yields the transformed referenced block content as virtual entities.
This method is meant to examine the block reference entities at the target
location without exploding the block reference.
These entities are not stored in the entity database, have no handle and
are not assigned to any layout. It is possible to convert these entities
into regular drawing entities by adding the entities to the entities
database and a layout of the same DXF document as the block reference::
doc.entitydb.add(entity)
msp = doc.modelspace()
msp.add_entity(entity)
.. warning::
**Non-uniform scale factors** may return incorrect results for some entities
(TEXT, MTEXT, ATTRIB).
This method does not resolve the MINSERT attributes, only the
sub-entities of the first INSERT will be returned. To resolve MINSERT
entities check if multi insert processing is required, that's the case
if the property :attr:`Insert.mcount` > 1, use the :meth:`Insert.multi_insert`
method to resolve the MINSERT entity into multiple INSERT entities.
This method does not apply the clipping path created by the XCLIP command.
The method returns all entities and ignores the clipping path polygon and no
entity is clipped.
The `skipped_entity_callback()` will be called for all entities which are not
processed, signature:
:code:`skipped_entity_callback(entity: DXFEntity, reason: str)`,
`entity` is the original (untransformed) DXF entity of the block definition, the
`reason` string is an explanation why the entity was skipped.
Args:
skipped_entity_callback: called whenever the transformation of an
entity is not supported and so was skipped
redraw_order: yield entities in ascending redraw order if ``True``
"""
for e in virtual_block_reference_entities(
self,
skipped_entity_callback=skipped_entity_callback,
redraw_order=redraw_order,
):
e.set_source_block_reference(self)
yield e
@property
def mcount(self) -> int:
"""Returns the multi-insert count, MINSERT (multi-insert) processing
is required if :attr:`mcount` > 1.
"""
return (self.dxf.row_count if self.dxf.row_spacing else 1) * (
self.dxf.column_count if self.dxf.column_spacing else 1
)
def multi_insert(self) -> Iterator[Insert]:
"""Yields a virtual INSERT entity for each grid element of a MINSERT
entity (multi-insert).
"""
def transform_attached_attrib_entities(insert, offset):
for attrib in insert.attribs:
attrib.dxf.insert += offset
def adjust_dxf_attribs(insert, offset):
dxf = insert.dxf
dxf.insert += offset
dxf.discard("row_count")
dxf.discard("column_count")
dxf.discard("row_spacing")
dxf.discard("column_spacing")
done = set()
row_spacing = self.dxf.row_spacing
col_spacing = self.dxf.column_spacing
rotation = self.dxf.rotation
for row in range(self.dxf.row_count):
for col in range(self.dxf.column_count):
# All transformations in OCS:
offset = Vec3(col * col_spacing, row * row_spacing)
# If any spacing is 0, yield only unique locations:
if offset not in done:
done.add(offset)
if rotation: # Apply rotation to the grid.
offset = offset.rotate_deg(rotation)
# Do not apply scaling to the grid!
insert = self.copy()
adjust_dxf_attribs(insert, offset)
transform_attached_attrib_entities(insert, offset)
yield insert
def add_auto_attribs(self, values: dict[str, str]) -> Insert:
"""
Attach for each :class:`~ezdxf.entities.Attdef` entity, defined in the
block definition, automatically an :class:`Attrib` entity to the block
reference and set ``tag/value`` DXF attributes of the ATTRIB entities
by the ``key/value`` pairs (both as strings) of the `values` dict.
The ATTRIB entities are placed relative to the insert location of the
block reference, which is identical to the block base point.
This method avoids the wrapper block of the
:meth:`~ezdxf.layouts.BaseLayout.add_auto_blockref` method, but the
visual results may not match the results of CAD applications, especially
for non-uniform scaling. If the visual result is very important to you,
use the :meth:`add_auto_blockref` method.
Args:
values: :class:`~ezdxf.entities.Attrib` tag values as ``tag/value``
pairs
"""
def unpack(dxfattribs) -> tuple[str, str, UVec]:
tag = dxfattribs.pop("tag")
text = values.get(tag, None)
if text is None: # get default value from ATTDEF
text = dxfattribs.get("text", "")
location = dxfattribs.pop("insert")
return tag, text, location
def autofill() -> None:
for attdef in block_layout.attdefs(): # type: ignore
dxfattribs = attdef.dxfattribs(drop={"prompt", "handle"})
# Caution! Some mandatory values may not exist!
# These are DXF structure errors, but loaded DXF files may have errors!
if "tag" not in dxfattribs:
# ATTRIB without "tag" makes no sense!
logger.warning(
f"Skipping {str(attdef)}: missing mandatory 'tag' attribute"
)
continue
if "insert" not in dxfattribs:
# Don't know where to place the ATTRIB entity.
logger.warning(
f"Skipping {str(attdef)}: missing mandatory 'insert' attribute"
)
continue
tag, text, location = unpack(dxfattribs)
attrib = self.add_attrib(tag, text, location, dxfattribs)
if attdef.has_embedded_mtext_entity:
mtext = attdef.virtual_mtext_entity()
mtext.text = text
attrib.embed_mtext(mtext)
attrib.transform(m)
block_layout = self.block()
if block_layout is not None:
m = self.matrix44()
autofill()
return self
def audit(self, auditor: Auditor) -> None:
"""Validity check."""
super().audit(auditor)
doc = auditor.doc
if doc and doc.blocks:
name = self.dxf.name
if name is None:
auditor.fixed_error(
code=AuditError.UNDEFINED_BLOCK_NAME,
message=f"Deleted entity {str(self)} without a BLOCK name",
)
auditor.trash(self)
elif name not in doc.blocks:
auditor.fixed_error(
code=AuditError.UNDEFINED_BLOCK,
message=f"Deleted entity {str(self)} without required BLOCK"
f" definition.",
)
auditor.trash(self)
def __referenced_blocks__(self) -> Iterable[str]:
"""Support for the "ReferencedBlocks" protocol."""
block = self.block()
if block is not None:
return (block.block_record_handle,)
return tuple()
@@ -0,0 +1,794 @@
# Copyright (c) 2019-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, cast, Any
from typing_extensions import Self
import logging
from dataclasses import dataclass
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,
SUBCLASS_MARKER,
DXF2000,
DXF2007,
DXF2004,
INVALID_NAME_CHARACTERS,
DXFValueError,
LINEWEIGHT_BYBLOCK,
LINEWEIGHT_BYLAYER,
LINEWEIGHT_DEFAULT,
)
from ezdxf.audit import AuditError
from ezdxf.entities.dxfentity import base_class, SubclassProcessor, DXFEntity
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace, Viewport, XRecord
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.entitydb import EntityDB
from ezdxf import xref
from ezdxf.audit import Auditor
__all__ = ["Layer", "acdb_symbol_table_record", "LayerOverrides"]
logger = logging.getLogger("ezdxf")
def is_valid_layer_color_index(aci: int) -> bool:
# BYBLOCK or BYLAYER is not valid a layer color!
return (-256 < aci < 256) and aci != 0
def fix_layer_color(aci: int) -> int:
return aci if is_valid_layer_color_index(aci) else 7
def is_valid_layer_lineweight(lw: int) -> bool:
if validator.is_valid_lineweight(lw):
if lw not in (LINEWEIGHT_BYLAYER, LINEWEIGHT_BYBLOCK):
return True
return False
def fix_layer_lineweight(lw: int) -> int:
if lw in (LINEWEIGHT_BYLAYER, LINEWEIGHT_BYBLOCK):
return LINEWEIGHT_DEFAULT
else:
return validator.fix_lineweight(lw)
acdb_symbol_table_record: DefSubclass = DefSubclass("AcDbSymbolTableRecord", {})
acdb_layer_table_record = DefSubclass(
"AcDbLayerTableRecord",
{
# Layer name as string
"name": DXFAttr(2, validator=validator.is_valid_layer_name),
"flags": DXFAttr(70, default=0),
# ACI color index, color < 0 indicates layer status: off
"color": DXFAttr(
62,
default=7,
validator=is_valid_layer_color_index,
fixer=fix_layer_color,
),
# True color as 24 bit int value: 0x00RRGGBB
"true_color": DXFAttr(420, dxfversion=DXF2004, optional=True),
# Linetype name as string
"linetype": DXFAttr(
6, default="Continuous", validator=validator.is_valid_table_name
),
# 0 = don't plot layer; 1 = plot layer
"plot": DXFAttr(
290,
default=1,
dxfversion=DXF2000,
optional=True,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Default lineweight 1/100 mm, min 0 = 0.0mm, max 211 = 2.11mm
"lineweight": DXFAttr(
370,
default=LINEWEIGHT_DEFAULT,
dxfversion=DXF2000,
validator=is_valid_layer_lineweight,
fixer=fix_layer_lineweight,
),
# Handle to PlotStyleName, group code 390 is required by AutoCAD
"plotstyle_handle": DXFAttr(390, dxfversion=DXF2000),
# Handle to Material object
"material_handle": DXFAttr(347, dxfversion=DXF2007),
# Handle to ???
"unknown1": DXFAttr(348, dxfversion=DXF2007, optional=True),
},
)
acdb_layer_table_record_group_codes = group_code_mapping(acdb_layer_table_record)
AcAecLayerStandard = "AcAecLayerStandard"
AcCmTransparency = "AcCmTransparency"
@register_entity
class Layer(DXFEntity):
"""DXF LAYER entity"""
DXFTYPE = "LAYER"
DXFATTRIBS = DXFAttributes(
base_class, acdb_symbol_table_record, acdb_layer_table_record
)
DEFAULT_ATTRIBS = {"name": "0"}
FROZEN = 0b00000001
THAW = 0b11111110
LOCK = 0b00000100
UNLOCK = 0b11111011
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.simple_dxfattribs_loader(
dxf, acdb_layer_table_record_group_codes # type: ignore
)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_symbol_table_record.name)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_layer_table_record.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"name",
"flags",
"color",
"true_color",
"linetype",
"plot",
"lineweight",
"plotstyle_handle",
"material_handle",
"unknown1",
],
)
def set_required_attributes(self):
assert self.doc is not None, "valid DXF document required"
if not self.dxf.hasattr("material_handle"):
global_ = self.doc.materials["Global"]
if isinstance(global_, DXFEntity):
handle = global_.dxf.handle
else:
handle = global_
self.dxf.material_handle = handle
if not self.dxf.hasattr("plotstyle_handle"):
normal = self.doc.plotstyles["Normal"]
if isinstance(normal, DXFEntity):
handle = normal.dxf.handle
else:
handle = normal
self.dxf.plotstyle_handle = handle
def is_frozen(self) -> bool:
"""Returns ``True`` if layer is frozen."""
return self.dxf.flags & Layer.FROZEN > 0
def freeze(self) -> None:
"""Freeze layer."""
self.dxf.flags = self.dxf.flags | Layer.FROZEN
def thaw(self) -> None:
"""Thaw layer."""
self.dxf.flags = self.dxf.flags & Layer.THAW
def is_locked(self) -> bool:
"""Returns ``True`` if layer is locked."""
return self.dxf.flags & Layer.LOCK > 0
def lock(self) -> None:
"""Lock layer, entities on this layer are not editable - just important
in CAD applications.
"""
self.dxf.flags = self.dxf.flags | Layer.LOCK
def unlock(self) -> None:
"""Unlock layer, entities on this layer are editable - just important
in CAD applications.
"""
self.dxf.flags = self.dxf.flags & Layer.UNLOCK
def is_off(self) -> bool:
"""Returns ``True`` if layer is off."""
return self.dxf.color < 0
def is_on(self) -> bool:
"""Returns ``True`` if layer is on."""
return not self.is_off()
def on(self) -> None:
"""Switch layer `on` (visible)."""
self.dxf.color = abs(self.dxf.color)
def off(self) -> None:
"""Switch layer `off` (invisible)."""
self.dxf.color = -abs(self.dxf.color)
def get_color(self) -> int:
"""Get layer color, safe method for getting the layer color, because
:attr:`dxf.color` is negative for layer status `off`.
"""
return abs(self.dxf.color)
def set_color(self, color: int) -> None:
"""Set layer color, safe method for setting the layer color, because
:attr:`dxf.color` is negative for layer status `off`.
"""
color = abs(color) if self.is_on() else -abs(color)
self.dxf.color = color
@property
def rgb(self) -> Optional[tuple[int, int, int]]:
"""Returns RGB true color as (r, g, b)-tuple or None if attribute
dxf.true_color is not set.
"""
if self.dxf.hasattr("true_color"):
return clr.int2rgb(self.dxf.get("true_color"))
else:
return None
@rgb.setter
def rgb(self, rgb: tuple[int, int, int]) -> None:
"""Set RGB true color as (r, g, b)-tuple e.g. (12, 34, 56)."""
self.dxf.set("true_color", clr.rgb2int(rgb))
@property
def color(self) -> int:
"""Get layer color, safe method for getting the layer color, because
:attr:`dxf.color` is negative for layer status `off`.
"""
return self.get_color()
@color.setter
def color(self, value: int) -> None:
"""Set layer color, safe method for setting the layer color, because
:attr:`dxf.color` is negative for layer status `off`.
"""
self.set_color(value)
@property
def description(self) -> str:
try:
xdata = self.get_xdata(AcAecLayerStandard)
except DXFValueError:
return ""
else:
if len(xdata) > 1:
# this is the usual case in BricsCAD
return xdata[1].value
else:
return ""
@description.setter
def description(self, value: str) -> None:
# create AppID table entry if not present
if self.doc and AcAecLayerStandard not in self.doc.appids:
self.doc.appids.new(AcAecLayerStandard)
self.discard_xdata(AcAecLayerStandard)
self.set_xdata(AcAecLayerStandard, [(1000, ""), (1000, value)])
@property
def transparency(self) -> float:
try:
xdata = self.get_xdata(AcCmTransparency)
except DXFValueError:
return 0.0
else:
t = xdata[0].value
if t & 0x2000000: # is this a real transparency value?
# Transparency BYBLOCK (0x01000000) make no sense for a layer!?
return clr.transparency2float(t)
return 0.0
@transparency.setter
def transparency(self, value: float) -> None:
# create AppID table entry if not present
if self.doc and AcCmTransparency not in self.doc.appids:
self.doc.appids.new(AcCmTransparency)
if 0 <= value <= 1:
self.discard_xdata(AcCmTransparency)
self.set_xdata(AcCmTransparency, [(1071, clr.float2transparency(value))])
else:
raise ValueError("Value out of range [0, 1].")
def rename(self, name: str) -> None:
"""
Rename layer and all known (documented) references to this layer.
.. warning::
The DXF format is not consistent in storing layer references, the
layers are mostly referenced by their case-insensitive name,
some later introduced entities do reference layers by handle, which
is the safer way in the context of renaming layers.
There is no complete overview of where layer references are
stored, third-party entities are black-boxes with unknown content
and layer names could be stored in the extended data section of any
DXF entity or in XRECORD entities.
Which means that in some rare cases references to the old layer name
can persist, at least this does not invalidate the DXF document.
Args:
name: new layer name
Raises:
ValueError: `name` contains invalid characters: <>/\\":;?*|=`
ValueError: layer `name` already exist
ValueError: renaming of layers ``'0'`` and ``'DEFPOINTS'`` not
possible
"""
if not validator.is_valid_layer_name(name):
raise ValueError(
f"Name contains invalid characters: {INVALID_NAME_CHARACTERS}."
)
assert self.doc is not None, "valid DXF document is required"
layers = self.doc.layers
if self.dxf.name.lower() in ("0", "defpoints"):
raise ValueError(f'Can not rename layer "{self.dxf.name}".')
if layers.has_entry(name):
raise ValueError(f'Layer "{name}" already exist.')
old = self.dxf.name
self.dxf.name = name
layers.replace(old, self)
self._rename_layer_references(old, name)
def _rename_layer_references(self, old_name: str, new_name: str) -> None:
assert self.doc is not None, "valid DXF document is required"
key = self.doc.layers.key
old_key = key(old_name)
for e in self.doc.entitydb.values():
if e.dxf.hasattr("layer") and key(e.dxf.layer) == old_key:
e.dxf.layer = new_name
entity_type = e.dxftype()
if entity_type == "VIEWPORT":
e.rename_frozen_layer(old_name, new_name) # type: ignore
elif entity_type == "LAYER_FILTER":
# todo: if LAYER_FILTER implemented, add support for
# renaming layers
logger.debug(
f'renaming layer "{old_name}" - document contains ' f"LAYER_FILTER"
)
elif entity_type == "LAYER_INDEX":
# todo: if LAYER_INDEX implemented, add support for
# renaming layers
logger.debug(
f'renaming layer "{old_name}" - document contains ' f"LAYER_INDEX"
)
def get_vp_overrides(self) -> LayerOverrides:
"""Returns the :class:`LayerOverrides` object for this layer."""
return LayerOverrides(self)
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
assert self.doc is not None, "LAYER entity must be assigned to a document"
super().register_resources(registry)
registry.add_linetype(self.dxf.linetype)
registry.add_handle(self.dxf.get("material_handle"))
# current plot style will be replaced by default plot style "Normal"
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
assert isinstance(clone, Layer)
super().map_resources(clone, mapping)
self.dxf.linetype = mapping.get_linetype(self.dxf.linetype)
mapping.map_existing_handle(self, clone, "material_handle", optional=True)
# remove handles pointing to the source document:
clone.dxf.discard("plotstyle_handle") # replaced by plot style "Normal"
clone.dxf.discard("unknown1")
# create required handles to resources in the target document
clone.set_required_attributes()
# todo: map layer overrides
# remove layer overrides
clone.discard_extension_dict()
def audit(self, auditor: Auditor) -> None:
super().audit(auditor)
linetype = self.dxf.linetype
if auditor.doc.linetypes.has_entry(linetype):
return
self.dxf.linetype = "Continuous"
auditor.fixed_error(
code=AuditError.UNDEFINED_LINETYPE,
message=f"Replaced undefined linetype {linetype} in layer '{self.dxf.name}' by CONTINUOUS",
dxf_entity=self,
data=linetype,
)
@dataclass
class OverrideAttributes:
aci: int
rgb: Optional[clr.RGB]
transparency: float
linetype: str
lineweight: int
class LayerOverrides:
"""This object stores the layer attribute overridden in VIEWPORT entities,
where each VIEWPORT can have individual layer attribute overrides.
Layer attributes which can be overridden:
- ACI color
- true color (rgb)
- linetype
- lineweight
- transparency
"""
def __init__(self, layer: Layer):
assert layer.doc is not None, "valid DXF document required"
self._layer = layer
self._overrides = load_layer_overrides(layer)
def has_overrides(self, vp_handle: Optional[str] = None) -> bool:
"""Returns ``True`` if attribute overrides exist for the given
:class:`Viewport` handle.
Returns ``True`` if `any` attribute overrides exist if the given
handle is ``None``.
"""
if vp_handle is None:
return bool(self._overrides)
return vp_handle in self._overrides
def commit(self) -> None:
"""Write :class:`Viewport` overrides back into the :class:`Layer` entity.
Without a commit() all changes are lost!
"""
store_layer_overrides(self._layer, self._overrides)
def _acquire_overrides(self, vp_handle: str) -> OverrideAttributes:
"""Returns the OverrideAttributes() instance for `vp_handle`, creates a new
OverrideAttributes() instance if none exist.
"""
return self._overrides.setdefault(
vp_handle,
default_ovr_settings(self._layer),
)
def _get_overrides(self, vp_handle: str) -> OverrideAttributes:
"""Returns the overrides for `vp_handle`, returns the default layer
settings if no Override() instance exist.
"""
try:
return self._overrides[vp_handle]
except KeyError:
return default_ovr_settings(self._layer)
def set_color(self, vp_handle: str, value: int) -> None:
"""Override the :ref:`ACI`.
Raises:
ValueError: invalid color value
"""
# BYBLOCK or BYLAYER is not valid a layer color
if not is_valid_layer_color_index(value):
raise ValueError(f"invalid ACI value: {value}")
vp_overrides = self._acquire_overrides(vp_handle)
vp_overrides.aci = value
def get_color(self, vp_handle: str) -> int:
"""Returns the :ref:`ACI` override or the original layer value if no
override exist.
"""
vp_overrides = self._get_overrides(vp_handle)
return vp_overrides.aci
def set_rgb(self, vp_handle: str, value: Optional[clr.RGB]):
"""Set the RGB override as (red, gree, blue) tuple or ``None`` to remove
the true color setting.
Raises:
ValueError: invalid RGB value
"""
if value is not None and not validator.is_valid_rgb(value):
raise ValueError(f"invalid RGB value: {value}")
vp_overrides = self._acquire_overrides(vp_handle)
vp_overrides.rgb = value
def get_rgb(self, vp_handle: str) -> Optional[clr.RGB]:
"""Returns the RGB override or the original layer value if no
override exist. Returns ``None`` if no true color value is set.
"""
vp_overrides = self._get_overrides(vp_handle)
return vp_overrides.rgb
def set_transparency(self, vp_handle: str, value: float) -> None:
"""Set the transparency override. A transparency of 0.0 is opaque and
1.0 is fully transparent.
Raises:
ValueError: invalid transparency value
"""
if not (0.0 <= value <= 1.0):
raise ValueError(
f"invalid transparency: {value}, has to be in the range [0, 1]"
)
vp_overrides = self._acquire_overrides(vp_handle)
vp_overrides.transparency = value
def get_transparency(self, vp_handle: str) -> float:
"""Returns the transparency override or the original layer value if no
override exist. Returns 0.0 for opaque and 1.0 for fully transparent.
"""
vp_overrides = self._get_overrides(vp_handle)
return vp_overrides.transparency
def set_linetype(self, vp_handle: str, value: str) -> None:
"""Set the linetype override.
Raises:
ValueError: linetype without a LTYPE table entry
"""
if value not in self._layer.doc.linetypes: # type: ignore
raise ValueError(
f"invalid linetype: {value}, a linetype table entry is required"
)
vp_overrides = self._acquire_overrides(vp_handle)
vp_overrides.linetype = value
def get_linetype(self, vp_handle: str) -> str:
"""Returns the linetype override or the original layer value if no
override exist.
"""
vp_overrides = self._get_overrides(vp_handle)
return vp_overrides.linetype
def get_lineweight(self, vp_handle: str) -> int:
"""Returns the lineweight override or the original layer value if no
override exist.
"""
vp_overrides = self._get_overrides(vp_handle)
return vp_overrides.lineweight
def set_lineweight(self, vp_handle: str, value: int) -> None:
"""Set the lineweight override.
Raises:
ValueError: invalid lineweight value
"""
if not is_valid_layer_lineweight(value):
raise ValueError(
f"invalid lineweight: {value}, a linetype table entry is required"
)
vp_overrides = self._acquire_overrides(vp_handle)
vp_overrides.lineweight = value
def discard(self, vp_handle: Optional[str] = None) -> None:
"""Discard all attribute overrides for the given :class:`Viewport`
handle or for all :class:`Viewport` entities if the handle is ``None``.
"""
if vp_handle is None:
self._overrides.clear()
return
try:
del self._overrides[vp_handle]
except KeyError:
pass
def default_ovr_settings(layer) -> OverrideAttributes:
"""Returns the default settings of the layer."""
return OverrideAttributes(
aci=layer.color,
rgb=layer.rgb,
transparency=layer.transparency,
linetype=layer.dxf.linetype,
lineweight=layer.dxf.lineweight,
)
def is_layer_frozen_in_vp(layer, vp_handle) -> bool:
"""Returns ``True`` if layer is frozen in VIEWPORT defined by the vp_handle."""
vp = cast("Viewport", layer.doc.entitydb.get(vp_handle))
if vp is not None:
return layer.dxf.name in vp.frozen_layers
return False
def load_layer_overrides(layer: Layer) -> dict[str, OverrideAttributes]:
"""Load all VIEWPORT overrides from the layer extension dictionary."""
def get_ovr(vp_handle: str):
ovr = overrides.get(vp_handle)
if ovr is None:
ovr = default_ovr_settings(layer)
overrides[vp_handle] = ovr
return ovr
def set_alpha(vp_handle: str, value: int):
ovr = get_ovr(vp_handle)
ovr.transparency = clr.transparency2float(value)
def set_color(vp_handle: str, value: int):
ovr = get_ovr(vp_handle)
type_, data = clr.decode_raw_color(value)
if type_ == clr.COLOR_TYPE_ACI:
ovr.aci = data
elif type_ == clr.COLOR_TYPE_RGB:
ovr.rgb = data
def set_ltype(vp_handle: str, lt_handle: str):
ltype = entitydb.get(lt_handle)
if ltype is not None:
ovr = get_ovr(vp_handle)
ovr.linetype = ltype.dxf.name
def set_lw(vp_handle: str, value: int):
ovr = get_ovr(vp_handle)
ovr.lineweight = value
def set_xdict_state():
xdict = layer.get_extension_dict()
for key, code, setter in [
(const.OVR_ALPHA_KEY, const.OVR_ALPHA_CODE, set_alpha),
(const.OVR_COLOR_KEY, const.OVR_COLOR_CODE, set_color),
(const.OVR_LTYPE_KEY, const.OVR_LTYPE_CODE, set_ltype),
(const.OVR_LW_KEY, const.OVR_LW_CODE, set_lw),
]:
xrec = cast("XRecord", xdict.get(key))
if xrec is not None:
for vp_handle, value in _load_ovr_values(xrec, code):
setter(vp_handle, value)
assert layer.doc is not None, "valid DXF document required"
entitydb: EntityDB = layer.doc.entitydb
assert entitydb is not None, "valid entity database required"
overrides: dict[str, OverrideAttributes] = dict()
if not layer.has_extension_dict:
return overrides
set_xdict_state()
return overrides
def _load_ovr_values(xrec: XRecord, group_code):
tags = xrec.tags
handles = [value for code, value in tags.find_all(const.OVR_VP_HANDLE_CODE)]
values = [value for code, value in tags.find_all(group_code)]
return zip(handles, values)
def store_layer_overrides(
layer: Layer, overrides: dict[str, OverrideAttributes]
) -> None:
"""Store all VIEWPORT overrides in the layer extension dictionary.
Replaces all existing overrides!
"""
from ezdxf.lldxf.types import DXFTag
def get_xdict():
if layer.has_extension_dict:
return layer.get_extension_dict()
else:
return layer.new_extension_dict()
def set_xdict_tags(key: str, tags: list[DXFTag]):
from ezdxf.entities import XRecord
xdict = get_xdict()
xrec = xdict.get(key)
if not isinstance(xrec, XRecord) and xrec is not None:
logger.debug(
f"Found entity {str(xrec)} as override storage in {str(layer)} "
f"but expected XRECORD"
)
xrec = None
if xrec is None:
xrec = xdict.add_xrecord(key)
xrec.dxf.cloning = 1
xrec.reset(tags)
def del_xdict_tags(key: str):
if not layer.has_extension_dict:
return
xdict = layer.get_extension_dict()
xrec = xdict.get(key)
if xrec is not None:
xrec.destroy()
xdict.discard(key)
def make_tags(data: list[tuple[Any, str]], name: str, code: int) -> list[DXFTag]:
tags: list[DXFTag] = []
for value, vp_handle in data:
tags.extend(
[
DXFTag(102, name),
DXFTag(const.OVR_VP_HANDLE_CODE, vp_handle),
DXFTag(code, value),
DXFTag(102, "}"),
]
)
return tags
def collect_alphas():
for vp_handle, ovr in vp_exist.items():
if ovr.transparency != default.transparency:
yield clr.float2transparency(ovr.transparency), vp_handle
def collect_colors():
for vp_handle, ovr in vp_exist.items():
if ovr.aci != default.aci or ovr.rgb != default.rgb:
if ovr.rgb is None:
raw_color = clr.encode_raw_color(ovr.aci)
else:
raw_color = clr.encode_raw_color(ovr.rgb)
yield raw_color, vp_handle
def collect_linetypes():
for vp_handle, ovr in vp_exist.items():
if ovr.linetype != default.linetype:
ltype = layer.doc.linetypes.get(ovr.linetype)
if ltype is not None:
yield ltype.dxf.handle, vp_handle
def collect_lineweights():
for vp_handle, ovr in vp_exist.items():
if ovr.lineweight != default.lineweight:
yield ovr.lineweight, vp_handle
assert layer.doc is not None, "valid DXF document required"
entitydb = layer.doc.entitydb
vp_exist = {
vp_handle: ovr
for vp_handle, ovr in overrides.items()
if (vp_handle in entitydb) and entitydb[vp_handle].is_alive
}
default = default_ovr_settings(layer)
alphas = list(collect_alphas())
if alphas:
tags = make_tags(alphas, const.OVR_APP_ALPHA, const.OVR_ALPHA_CODE)
set_xdict_tags(const.OVR_ALPHA_KEY, tags)
else:
del_xdict_tags(const.OVR_ALPHA_KEY)
colors = list(collect_colors())
if colors:
tags = make_tags(colors, const.OVR_APP_COLOR, const.OVR_COLOR_CODE)
set_xdict_tags(const.OVR_COLOR_KEY, tags)
else:
del_xdict_tags(const.OVR_COLOR_KEY)
linetypes = list(collect_linetypes())
if linetypes:
tags = make_tags(linetypes, const.OVR_APP_LTYPE, const.OVR_LTYPE_CODE)
set_xdict_tags(const.OVR_LTYPE_KEY, tags)
else:
del_xdict_tags(const.OVR_LTYPE_KEY)
lineweights = list(collect_lineweights())
if lineweights:
tags = make_tags(lineweights, const.OVR_APP_LW, const.OVR_LW_CODE)
set_xdict_tags(const.OVR_LW_KEY, tags)
else:
del_xdict_tags(const.OVR_LW_KEY)
@@ -0,0 +1,394 @@
# Copyright (c) 2020-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing_extensions import Self
from ezdxf.lldxf import validator
from ezdxf.lldxf.const import SUBCLASS_MARKER
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.math import Vec3, Vec2, NULLVEC, X_AXIS, Y_AXIS
from .dxfentity import base_class, SubclassProcessor, DXFEntity
from .dxfobj import DXFObject
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.entities.dxfns import DXFNamespace
from ezdxf import xref
__all__ = ["PlotSettings", "DXFLayout"]
acdb_plot_settings = DefSubclass(
"AcDbPlotSettings",
{
# acdb_plot_settings is also part of LAYOUT and LAYOUT has a 'name' attribute
"page_setup_name": DXFAttr(1, default=""),
# An optional empty string selects the default printer/plotter
"plot_configuration_file": DXFAttr(2, default="", optional=True),
"paper_size": DXFAttr(4, default="A3"),
"plot_view_name": DXFAttr(6, default=""),
"left_margin": DXFAttr(40, default=7.5), # in mm
"bottom_margin": DXFAttr(41, default=20), # in mm
"right_margin": DXFAttr(42, default=7.5), # in mm
"top_margin": DXFAttr(43, default=20), # in mm
"paper_width": DXFAttr(44, default=420), # in mm
"paper_height": DXFAttr(45, default=297), # in mm
"plot_origin_x_offset": DXFAttr(46, default=0.0), # in mm
"plot_origin_y_offset": DXFAttr(47, default=0.0), # in mm
"plot_window_x1": DXFAttr(48, default=0.0),
"plot_window_y1": DXFAttr(49, default=0.0),
"plot_window_x2": DXFAttr(140, default=0.0),
"plot_window_y2": DXFAttr(141, default=0.0),
# Numerator of custom print scale: real world (paper) units:
"scale_numerator": DXFAttr(142, default=1.0),
# Denominator of custom print scale: drawing units:
"scale_denominator": DXFAttr(143, default=1.0),
# Plot layout flags:
# 1 = plot viewport borders
# 2 = show plot-styles
# 4 = plot centered
# 8 = plot hidden == hide paperspace entities?
# 16 = use standard scale
# 32 = plot with plot-styles
# 64 = scale lineweights
# 128 = plot entity lineweights
# 512 = draw viewports first
# 1024 = model type
# 2048 = update paper
# 4096 = zoom to paper on update
# 8192 = initializing
# 16384 = prev plot-init
# the "Plot transparencies" option is stored in the XDATA section
"plot_layout_flags": DXFAttr(70, default=688),
# Plot paper units:
# 0 = Plot in inches
# 1 = Plot in millimeters
# 2 = Plot in pixels
"plot_paper_units": DXFAttr(
72,
default=1,
validator=validator.is_in_integer_range(0, 3),
fixer=RETURN_DEFAULT,
),
# Plot rotation:
# 0 = No rotation
# 1 = 90 degrees counterclockwise
# 2 = Upside-down
# 3 = 90 degrees clockwise
"plot_rotation": DXFAttr(
73,
default=0,
validator=validator.is_in_integer_range(0, 4),
fixer=RETURN_DEFAULT,
),
# Plot type:
# 0 = Last screen display
# 1 = Drawing extents
# 2 = Drawing limits
# 3 = View specified by code 6
# 4 = Window specified by codes 48, 49, 140, and 141
# 5 = Layout information
"plot_type": DXFAttr(
74,
default=5,
validator=validator.is_in_integer_range(0, 6),
fixer=RETURN_DEFAULT,
),
# Associated CTB-file
"current_style_sheet": DXFAttr(7, default=""),
# Standard scale type:
# 0 = Scaled to Fit
# 1 = 1/128"=1'
# 2 = 1/64"=1'
# 3 = 1/32"=1'
# 4 = 1/16"=1'
# 5 = 3/32"=1'
# 6 = 1/8"=1'
# 7 = 3/16"=1'
# 8 = 1/4"=1'
# 9 = 3/8"=1'
# 10 = 1/2"=1'
# 11 = 3/4"=1'
# 12 = 1"=1'
# 13 = 3"=1'
# 14 = 6"=1'
# 15 = 1'=1'
# 16 = 1:1
# 17 = 1:2
# 18 = 1:4
# 19 = 1:8
# 20 = 1:10
# 21 = 1:16
# 22 = 1:20
# 23 = 1:30
# 24 = 1:40
# 25 = 1:50
# 26 = 1:100
# 27 = 2:1
# 28 = 4:1
# 29 = 8:1
# 30 = 10:1
# 31 = 100:1
# 32 = 1000:1
"standard_scale_type": DXFAttr(
75,
default=16,
validator=validator.is_in_integer_range(0, 33),
fixer=RETURN_DEFAULT,
),
# Shade plot mode:
# 0 = As Displayed
# 1 = Wireframe
# 2 = Hidden
# 3 = Rendered
"shade_plot_mode": DXFAttr(
76,
default=0,
validator=validator.is_in_integer_range(0, 4),
fixer=RETURN_DEFAULT,
),
# Shade plot resolution level:
# 0 = Draft
# 1 = Preview
# 2 = Normal
# 3 = Presentation
# 4 = Maximum
# 5 = Custom
"shade_plot_resolution_level": DXFAttr(
77,
default=2,
validator=validator.is_in_integer_range(0, 6),
fixer=RETURN_DEFAULT,
),
# Valid range: 100 to 32767, Only applied when the shade_plot_resolution
# level is set to 5 (Custom)
"shade_plot_custom_dpi": DXFAttr(
78,
default=300,
validator=validator.is_in_integer_range(100, 32768),
fixer=validator.fit_into_integer_range(100, 32768),
),
# Factor for unit conversion (mm -> inches)
# 147: DXF Reference error: 'A floating point scale factor that represents
# the standard scale value specified in code 75'
"unit_factor": DXFAttr(
147,
default=1.0,
validator=validator.is_greater_zero,
fixer=RETURN_DEFAULT,
),
"paper_image_origin_x": DXFAttr(148, default=0),
"paper_image_origin_y": DXFAttr(149, default=0),
"shade_plot_handle": DXFAttr(333, optional=True),
},
)
acdb_plot_settings_group_codes = group_code_mapping(acdb_plot_settings)
# The "Plot transparencies" option is stored in the XDATA section of the
# LAYOUT entity:
# 1001
# PLOTTRANSPARENCY
# 1071
# 1
@register_entity
class PlotSettings(DXFObject):
DXFTYPE = "PLOTSETTINGS"
DXFATTRIBS = DXFAttributes(base_class, acdb_plot_settings)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_plot_settings_group_codes, 1)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_plot_settings.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"page_setup_name",
"plot_configuration_file",
"paper_size",
"plot_view_name",
"left_margin",
"bottom_margin",
"right_margin",
"top_margin",
"paper_width",
"paper_height",
"plot_origin_x_offset",
"plot_origin_y_offset",
"plot_window_x1",
"plot_window_y1",
"plot_window_x2",
"plot_window_y2",
"scale_numerator",
"scale_denominator",
"plot_layout_flags",
"plot_paper_units",
"plot_rotation",
"plot_type",
"current_style_sheet",
"standard_scale_type",
"shade_plot_mode",
"shade_plot_resolution_level",
"shade_plot_custom_dpi",
"unit_factor",
"paper_image_origin_x",
"paper_image_origin_y",
],
)
def register_resources(self, registry: xref.Registry) -> None:
super().register_resources(registry)
registry.add_handle(self.dxf.get("shade_plot_handle"))
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
super().map_resources(clone, mapping)
shade_plot_handle = self.dxf.get("shade_plot_handle")
if shade_plot_handle and shade_plot_handle != "0":
clone.dxf.shade_plot_handle = mapping.get_handle(shade_plot_handle)
else:
clone.dxf.discard("shade_plot_handle")
acdb_layout = DefSubclass(
"AcDbLayout",
{
# Layout name:
"name": DXFAttr(1, default="Layoutname"),
# Flag (bit-coded) to control the following:
# 1 = Indicates the PSLTSCALE value for this layout when this layout is current
# 2 = Indicates the LIMCHECK value for this layout when this layout is current
"layout_flags": DXFAttr(70, default=1),
# Tab order: This number is an ordinal indicating this layout's ordering in
# the tab control that is attached to the AutoCAD drawing frame window.
# Note that the "Model" tab always appears as the first tab regardless of
# its tab order.
"taborder": DXFAttr(71, default=1),
# Minimum limits:
"limmin": DXFAttr(10, xtype=XType.point2d, default=Vec2(0, 0)),
# Maximum limits:
"limmax": DXFAttr(11, xtype=XType.point2d, default=Vec2(420, 297)),
# Insertion base point for this layout:
"insert_base": DXFAttr(12, xtype=XType.point3d, default=NULLVEC),
# Minimum extents for this layout:
"extmin": DXFAttr(14, xtype=XType.point3d, default=Vec3(1e20, 1e20, 1e20)),
# Maximum extents for this layout:
"extmax": DXFAttr(15, xtype=XType.point3d, default=Vec3(-1e20, -1e20, -1e20)),
"elevation": DXFAttr(146, default=0.0),
"ucs_origin": DXFAttr(13, xtype=XType.point3d, default=NULLVEC),
"ucs_xaxis": DXFAttr(
16,
xtype=XType.point3d,
default=X_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
"ucs_yaxis": DXFAttr(
17,
xtype=XType.point3d,
default=Y_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# Orthographic type of UCS:
# 0 = UCS is not orthographic
# 1 = Top
# 2 = Bottom
# 3 = Front
# 4 = Back
# 5 = Left
# 6 = Right
"ucs_type": DXFAttr(
76,
default=1,
validator=validator.is_in_integer_range(0, 7),
fixer=RETURN_DEFAULT,
),
# Handle of parent BLOCK_RECORD
"block_record_handle": DXFAttr(330),
# Handle to the viewport that was last active in this
# layout when the layout was current:
"viewport_handle": DXFAttr(331),
# Handle of AcDbUCSTableRecord if UCS is a named
# UCS. If not present, then UCS is unnamed
"ucs_handle": DXFAttr(345),
# Handle of AcDbUCSTableRecord of base UCS if UCS is
# orthographic (76 code is non-zero). If not present and
# 76 code is non-zero, then base UCS is taken to be WORLD
"base_ucs_handle": DXFAttr(346),
},
)
acdb_layout_group_codes = group_code_mapping(acdb_layout)
@register_entity
class DXFLayout(PlotSettings):
DXFTYPE = "LAYOUT"
DXFATTRIBS = DXFAttributes(base_class, acdb_plot_settings, acdb_layout)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_layout_group_codes, 2)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
# Set correct model type flag
self.set_flag_state(1024, self.dxf.name.upper() == "MODEL", "plot_layout_flags")
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_layout.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"name",
"layout_flags",
"taborder",
"limmin",
"limmax",
"insert_base",
"extmin",
"extmax",
"elevation",
"ucs_origin",
"ucs_xaxis",
"ucs_yaxis",
"ucs_type",
"block_record_handle",
"viewport_handle",
"ucs_handle",
"base_ucs_handle",
],
)
def register_resources(self, registry: xref.Registry) -> None:
super().register_resources(registry)
registry.add_handle(self.dxf.get("ucs_handle"))
registry.add_handle(self.dxf.get("base_ucs_handle"))
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
super().map_resources(clone, mapping)
# The content of paperspace layouts is not copied automatically and the
# associated BLOCK_RECORD is created and assigned in a special method.
mapping.map_existing_handle(self, clone, "ucs_handle", optional=True)
mapping.map_existing_handle(self, clone, "base_ucs_handle", optional=True)
mapping.map_existing_handle(self, clone, "viewport_handle", optional=True)
@@ -0,0 +1,360 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Optional, Iterator
from typing_extensions import Self
import logging
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.tags import Tags, DXFTag
from ezdxf.lldxf import const
from ezdxf.math import Vec3, UVec, X_AXIS, Z_AXIS, NULLVEC
from ezdxf.math.transformtools import transform_extrusion
from ezdxf.explode import explode_entity
from ezdxf.audit import AuditError
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity
from .factory import register_entity
from .dimension import OverrideMixin, register_override_handles
from .dimstyleoverride import DimStyleOverride
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.audit import Auditor
from ezdxf.entities import DXFNamespace, DXFEntity
from ezdxf.layouts import BaseLayout
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.math import Matrix44
from ezdxf.query import EntityQuery
from ezdxf import xref
__all__ = ["Leader"]
logger = logging.getLogger("ezdxf")
acdb_leader = DefSubclass(
"AcDbLeader",
{
"dimstyle": DXFAttr(
3,
default="Standard",
validator=validator.is_valid_table_name,
# no fixer!
),
# Arrowhead flag: 0/1 = no/yes
"has_arrowhead": DXFAttr(
71,
default=1,
optional=True,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Leader path type:
# 0 = Straight line segments
# 1 = Spline
"path_type": DXFAttr(
72,
default=0,
optional=True,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Annotation type or leader creation flag:
# 0 = Created with text annotation
# 1 = Created with tolerance annotation;
# 2 = Created with block reference annotation
# 3 = Created without any annotation
"annotation_type": DXFAttr(
73,
default=3,
validator=validator.is_in_integer_range(0, 4),
fixer=RETURN_DEFAULT,
),
# Hook line direction flag:
# 1 = Hook line (or end of tangent for a spline leader) is the opposite
# direction from the horizontal vector
# 0 = Hook line (or end of tangent for a spline leader) is the same
# direction as horizontal vector (see code 75)
# DXF reference error: swapped meaning of 1/0
"hookline_direction": DXFAttr(
74,
default=1,
optional=True,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Hook line flag: 0/1 = no/yes
"has_hookline": DXFAttr(
75,
default=1,
optional=True,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Text annotation height:
"text_height": DXFAttr(
40,
default=1,
optional=True,
validator=validator.is_greater_zero,
fixer=RETURN_DEFAULT,
),
# Text annotation width:
"text_width": DXFAttr(
41,
default=1,
optional=True,
validator=validator.is_greater_zero,
fixer=RETURN_DEFAULT,
),
# 76: Number of vertices in leader (ignored for OPEN)
# 10, 20, 30: Vertex coordinates (one entry for each vertex)
# Color to use if leader's DIMCLRD = BYBLOCK
"block_color": DXFAttr(
77,
default=7,
optional=True,
validator=validator.is_valid_aci_color,
fixer=RETURN_DEFAULT,
),
# Hard reference to associated annotation:
# (mtext, tolerance, or insert entity)
"annotation_handle": DXFAttr(340, default="0", optional=True),
"normal_vector": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# 'horizontal' direction for leader
"horizontal_direction": DXFAttr(
211,
xtype=XType.point3d,
default=X_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# Offset of last leader vertex from block reference insertion point
"leader_offset_block_ref": DXFAttr(
212, xtype=XType.point3d, default=NULLVEC, optional=True
),
# Offset of last leader vertex from annotation placement point
"leader_offset_annotation_placement": DXFAttr(
213, xtype=XType.point3d, default=NULLVEC, optional=True
),
# Xdata belonging to the application ID "ACAD" follows a leader entity if
# any dimension overrides have been applied to this entity. See Dimension
# Style Overrides.
},
)
acdb_leader_group_codes = group_code_mapping(acdb_leader)
@register_entity
class Leader(DXFGraphic, OverrideMixin):
"""DXF LEADER entity"""
DXFTYPE = "LEADER"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_leader)
MIN_DXF_VERSION_FOR_EXPORT = const.DXF2000
def __init__(self) -> None:
super().__init__()
self.vertices: list[Vec3] = []
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy vertices."""
assert isinstance(entity, Leader)
entity.vertices = Vec3.list(self.vertices)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.subclass_by_index(2)
if tags:
tags = Tags(self.load_vertices(tags))
processor.fast_load_dxfattribs(
dxf, acdb_leader_group_codes, tags, recover=True
)
else:
raise const.DXFStructureError(
f"missing 'AcDbLeader' subclass in LEADER(#{dxf.handle})"
)
return dxf
def load_vertices(self, tags: Tags) -> Iterable[DXFTag]:
for tag in tags:
if tag.code == 10:
self.vertices.append(tag.value)
elif tag.code == 76:
# Number of vertices in leader (ignored for OPEN)
pass
else:
yield tag
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
if len(self.vertices) < 2:
logger.debug(f"Invalid {str(self)}: more than 1 vertex required.")
return False
else:
return True
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_leader.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"dimstyle",
"has_arrowhead",
"path_type",
"annotation_type",
"hookline_direction",
"has_hookline",
"text_height",
"text_width",
],
)
self.export_vertices(tagwriter)
self.dxf.export_dxf_attribs(
tagwriter,
[
"block_color",
"annotation_handle",
"normal_vector",
"horizontal_direction",
"leader_offset_block_ref",
"leader_offset_annotation_placement",
],
)
def export_vertices(self, tagwriter: AbstractTagWriter) -> None:
tagwriter.write_tag2(76, len(self.vertices))
for vertex in self.vertices:
tagwriter.write_vertex(10, vertex)
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
assert self.doc is not None
super().register_resources(registry)
registry.add_dim_style(self.dxf.dimstyle)
# The leader entity cannot register the annotation entity!
if not self.has_xdata_list("ACAD", "DSTYLE"):
return
if self.doc.dxfversion > const.DXF12:
# overridden resources are referenced by handle
register_override_handles(self, registry)
else:
# overridden resources are referenced by name
self.override().register_resources_r12(registry)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
super().map_resources(clone, mapping)
if self.dxf.hasattr("annotation_handle"):
clone.dxf.annotation_handle = mapping.get_handle(self.dxf.annotation_handle)
# DXF R2000+ references overridden resources by group code 1005 handles in the
# XDATA section, which are automatically mapped by the parent class DXFEntity!
assert self.doc is not None
if self.doc.dxfversion > const.DXF12:
return
self_override = self.override()
if not self_override.dimstyle_attribs:
return # has no overrides
assert isinstance(clone, Leader)
self_override.map_resources_r12(clone, mapping)
def override(self) -> DimStyleOverride:
"""Returns the :class:`~ezdxf.entities.DimStyleOverride` object.
.. warning::
The LEADER entity shares only the DIMSTYLE override infrastructure with the
DIMENSION entity but does not support any other features of the DIMENSION
entity!
HANDLE WITH CARE!
"""
return DimStyleOverride(self) # type: ignore
def set_vertices(self, vertices: Iterable[UVec]):
"""Set vertices of the leader, vertices is an iterable of
(x, y [,z]) tuples or :class:`~ezdxf.math.Vec3`.
"""
self.vertices = [Vec3(v) for v in vertices]
def transform(self, m: Matrix44) -> Leader:
"""Transform LEADER entity by transformation matrix `m` inplace."""
self.vertices = list(m.transform_vertices(self.vertices))
self.dxf.normal_vector, _ = transform_extrusion(
self.dxf.normal_vector, m
) # ???
self.dxf.horizontal_direction = m.transform_direction(
self.dxf.horizontal_direction
)
self.post_transform(m)
return self
def __virtual_entities__(self) -> Iterator[DXFGraphic]:
"""Implements the SupportsVirtualEntities protocol."""
from ezdxf.render.leader import virtual_entities
for e in virtual_entities(self):
e.set_source_of_copy(self)
yield e
def virtual_entities(self) -> Iterator[DXFGraphic]:
"""Yields the DXF primitives the LEADER entity is build up as virtual entities.
These entities are located at the original location, but are not stored
in the entity database, have no handle and are not assigned to any
layout.
"""
return self.__virtual_entities__()
def explode(self, target_layout: Optional[BaseLayout] = None) -> EntityQuery:
"""Explode parts of the LEADER entity as DXF primitives into target layout,
if target layout is ``None``, the target layout is the layout of the LEADER
entity. This method destroys the source entity.
Returns an :class:`~ezdxf.query.EntityQuery` container referencing all
DXF primitives.
Args:
target_layout: target layout for the created DXF primitives, ``None`` for
the same layout as the source entity.
"""
return explode_entity(self, target_layout)
def audit(self, auditor: Auditor) -> None:
"""Validity check."""
super().audit(auditor)
if len(self.vertices) < 2:
auditor.fixed_error(
code=AuditError.INVALID_VERTEX_COUNT,
message=f"Deleted entity {str(self)} with invalid vertex count "
f"= {len(self.vertices)}.",
dxf_entity=self,
)
self.destroy()
@@ -0,0 +1,152 @@
# Copyright (c) 2019-2022, Manfred Moitzi
# License: MIT-License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.lldxf import validator
from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2007
from ezdxf.lldxf.attributes import (
DXFAttributes,
DefSubclass,
DXFAttr,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import acdb_entity, DXFGraphic
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.math import Matrix44
__all__ = ["Light"]
acdb_light = DefSubclass(
"AcDbLight",
{
"version": DXFAttr(90, default=0),
# Light name
"name": DXFAttr(1, default=""),
# Light type:
# 1 = distant
# 2 = point
# 3 = spot
"type": DXFAttr(
70,
default=1,
validator=validator.is_in_integer_range(1, 4),
fixer=RETURN_DEFAULT,
),
"status": DXFAttr(
290,
default=1,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"plot_glyph": DXFAttr(
291,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"intensity": DXFAttr(40, default=1),
# Light position
"location": DXFAttr(10, xtype=XType.point3d),
# Target location
"target": DXFAttr(11, xtype=XType.point3d),
# Attenuation type:
# 0 = None
# 1 = Inverse Linear
# 2 = Inverse Square
"attenuation_type": DXFAttr(
72,
default=2,
validator=validator.is_in_integer_range(0, 3),
fixer=RETURN_DEFAULT,
),
"use_attenuation_limits": DXFAttr(
292,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"attenuation_start_limits": DXFAttr(41),
"attenuation_end_limits": DXFAttr(42),
"hotspot_angle": DXFAttr(50), # in degrees
"falloff_angle": DXFAttr(51), # in degrees
"cast_shadows": DXFAttr(
293,
default=1,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Shadow Type:
# 0 = Ray traced shadows
# 1 = Shadow maps
"shadow_type": DXFAttr(
73,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"shadow_map_size": DXFAttr(91),
"shadow_map_softness": DXFAttr(280),
},
)
acdb_light_group_codes = group_code_mapping(acdb_light)
@register_entity
class Light(DXFGraphic):
"""DXF LIGHT entity"""
DXFTYPE = "LIGHT"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_light)
MIN_DXF_VERSION_FOR_EXPORT = DXF2007
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_light_group_codes, 2, recover=True
)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_light.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"version",
"name",
"type",
"status",
"plot_glyph",
"intensity",
"location",
"target",
"attenuation_type",
"use_attenuation_limits",
"attenuation_start_limits",
"attenuation_end_limits",
"hotspot_angle",
"falloff_angle",
"cast_shadows",
"shadow_type",
"shadow_map_size",
"shadow_map_softness",
],
)
def transform(self, m: Matrix44) -> Light:
"""Transform the LIGHT entity by transformation matrix `m` inplace."""
self.dxf.location = m.transform(self.dxf.location)
self.dxf.target = m.transform(self.dxf.target)
self.post_transform(m)
return self
@@ -0,0 +1,110 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
merge_group_code_mappings,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER
from ezdxf.math import Vec3, Matrix44, NULLVEC, Z_AXIS, OCS
from ezdxf.math.transformtools import (
transform_thickness_and_extrusion_without_ocs,
)
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity, acdb_entity_group_codes
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["Line"]
acdb_line = DefSubclass(
"AcDbLine",
{
"start": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
"end": DXFAttr(11, xtype=XType.point3d, default=NULLVEC),
"thickness": DXFAttr(39, default=0, optional=True),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
},
)
acdb_line_group_codes = group_code_mapping(acdb_line)
merged_line_group_codes = merge_group_code_mappings(
acdb_entity_group_codes, acdb_line_group_codes # type: ignore
)
@register_entity
class Line(DXFGraphic):
"""The LINE entity represents a 3D line from `start` to `end`"""
DXFTYPE = "LINE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_line)
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, merged_line_group_codes)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags. (internal API)"""
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_line.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"start",
"end",
"thickness",
"extrusion",
],
)
def ocs(self) -> OCS:
# WCS entity which supports the "extrusion" attribute in a
# different way!
return OCS()
def transform(self, m: Matrix44) -> Line:
"""Transform the LINE entity by transformation matrix `m` inplace."""
start, end = m.transform_vertices([self.dxf.start, self.dxf.end])
self.dxf.start = start
self.dxf.end = end
transform_thickness_and_extrusion_without_ocs(self, m)
self.post_transform(m)
return self
def translate(self, dx: float, dy: float, dz: float) -> Line:
"""Optimized LINE translation about `dx` in x-axis, `dy` in y-axis and
`dz` in z-axis.
"""
vec = Vec3(dx, dy, dz)
self.dxf.start = vec + self.dxf.start
self.dxf.end = vec + self.dxf.end
# Avoid Matrix44 instantiation if not required:
if self.is_post_transform_required:
self.post_transform(Matrix44.translate(dx, dy, dz))
return self
@@ -0,0 +1,270 @@
# Copyright (c) 2019-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Union,
Iterable,
Sequence,
Optional,
)
from typing_extensions import Self
from copy import deepcopy
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
group_code_mapping,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER
from ezdxf.lldxf.types import DXFTag
from ezdxf.lldxf.tags import Tags
from ezdxf.entities.dxfentity import base_class, SubclassProcessor, DXFEntity
from ezdxf.entities.layer import acdb_symbol_table_record
from ezdxf.lldxf.validator import is_valid_table_name
from ezdxf.tools.complex_ltype import lin_compiler
from .factory import register_entity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf import xref
__all__ = ["Linetype", "compile_line_pattern", "CONTINUOUS_PATTERN"]
acdb_linetype = DefSubclass(
"AcDbLinetypeTableRecord",
{
"name": DXFAttr(2, validator=is_valid_table_name),
"description": DXFAttr(3, default=""),
"flags": DXFAttr(70, default=0),
# 'length': DXFAttr(40),
# 'items': DXFAttr(73),
},
)
acdb_linetype_group_codes = group_code_mapping(acdb_linetype)
CONTINUOUS_PATTERN: Sequence[float] = tuple()
class LinetypePattern:
def __init__(self, tags: Tags):
"""For now just store tags"""
self.tags = tags
def __len__(self):
return len(self.tags)
def export_dxf(self, tagwriter: AbstractTagWriter):
if tagwriter.dxfversion <= DXF12:
self.export_r12_dxf(tagwriter)
else:
tagwriter.write_tags(self.tags)
def export_r12_dxf(self, tagwriter: AbstractTagWriter):
tags49 = Tags(tag for tag in self.tags if tag.code == 49)
tagwriter.write_tag2(72, 65)
tagwriter.write_tag2(73, len(tags49))
tagwriter.write_tag(self.tags.get_first_tag(40))
if len(tags49):
tagwriter.write_tags(tags49)
def is_complex_type(self):
return self.tags.has_tag(340)
def get_style_handle(self):
return self.tags.get_first_value(340, "0")
def set_style_handle(self, handle):
return self.tags.update(DXFTag(340, handle))
def compile(self) -> Sequence[float]:
"""Returns the simplified dash-gap-dash... line pattern,
a dash-length of 0 represents a point.
"""
# complex line types with text and shapes are not supported
if self.is_complex_type():
return CONTINUOUS_PATTERN
pattern_length = 0.0
elements = []
for tag in self.tags:
if tag.code == 40:
pattern_length = tag.value
elif tag.code == 49:
elements.append(tag.value)
if len(elements) < 2:
return CONTINUOUS_PATTERN
return compile_line_pattern(pattern_length, elements)
def _merge_dashes(elements: Sequence[float]) -> Iterable[float]:
"""Merge multiple consecutive lines, gaps or points into a single element."""
def sign(v):
if v < 0:
return -1
elif v > 0:
return +1
return 0
buffer = elements[0]
prev_sign = sign(buffer)
for e in elements[1:]:
if sign(e) == prev_sign:
buffer += e
else:
yield buffer
buffer = e
prev_sign = sign(e)
yield buffer
def compile_line_pattern(
total_length: Optional[float], elements: Sequence[float]
) -> Sequence[float]:
"""Returns the simplified dash-gap-dash... line pattern,
a dash-length of 0 represents a point.
"""
elements = list(_merge_dashes(elements))
if total_length is None:
pass
elif len(elements) < 2 or total_length <= 0.0:
return CONTINUOUS_PATTERN
sum_elements = sum(abs(e) for e in elements)
if total_length and total_length > sum_elements: # append a gap
elements.append(sum_elements - total_length)
if elements[0] < 0: # start with a gap
e = elements.pop(0)
if elements[-1] < 0: # extend last gap
elements[-1] += e
else: # add last gap
elements.append(e)
# returns dash-gap-point
# possible: dash-point or point-dash - ignore this yet
# never: dash-dash or gap-gap or point-point
return tuple(abs(e) for e in elements)
@register_entity
class Linetype(DXFEntity):
"""DXF LTYPE entity"""
DXFTYPE = "LTYPE"
DXFATTRIBS = DXFAttributes(
base_class, acdb_symbol_table_record, acdb_linetype
)
def __init__(self):
"""Default constructor"""
super().__init__()
self.pattern_tags = LinetypePattern(Tags())
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy pattern_tags."""
assert isinstance(entity, Linetype)
entity.pattern_tags = deepcopy(self.pattern_tags)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.fast_load_dxfattribs(
dxf, acdb_linetype_group_codes, 2, log=False
)
self.pattern_tags = LinetypePattern(tags)
return dxf
def preprocess_export(self, tagwriter: AbstractTagWriter):
if len(self.pattern_tags) == 0:
return False
# Do not export complex linetypes for DXF12
if tagwriter.dxfversion == DXF12:
return not self.pattern_tags.is_complex_type()
return True
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
# AcDbEntity export is done by parent class
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_symbol_table_record.name)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_linetype.name)
self.dxf.export_dxf_attribs(tagwriter, ["name", "flags", "description"])
if self.pattern_tags:
self.pattern_tags.export_dxf(tagwriter)
def setup_pattern(
self, pattern: Union[Sequence[float], str], length: float = 0
) -> None:
# The new() function gets no doc reference, therefore complex linetype
# setup has to be done later. See also: LinetypeTable.new_entry()
complex_line_type = True if isinstance(pattern, str) else False
if complex_line_type: # a .lin like line type definition string
tags = self._setup_complex_pattern(pattern, length) # type: ignore
else:
# pattern: [2.0, 1.25, -0.25, 0.25, -0.25] - 1. element is total
# pattern length pattern elements: >0 line, <0 gap, =0 point
tags = Tags(
[
DXFTag(72, 65), # letter 'A'
DXFTag(73, len(pattern) - 1),
DXFTag(40, float(pattern[0])),
]
)
for element in pattern[1:]:
tags.append(DXFTag(49, float(element)))
tags.append(DXFTag(74, 0))
self.pattern_tags = LinetypePattern(tags)
def _setup_complex_pattern(self, pattern: str, length: float) -> Tags:
tokens = lin_compiler(pattern)
tags = Tags(
[
DXFTag(72, 65), # letter 'A'
]
)
tags2 = [DXFTag(73, 0), DXFTag(40, length)] # temp length of 0
count = 0
for token in tokens:
if isinstance(token, DXFTag):
if tags2[-1].code == 49: # useless 74 only after 49 :))
tags2.append(DXFTag(74, 0))
tags2.append(token)
count += 1
else: # TEXT or SHAPE
tags2.extend(token.complex_ltype_tags(self.doc))
tags2.append(DXFTag(74, 0)) # useless 74 at the end :))
tags2[0] = DXFTag(73, count)
tags.extend(tags2)
return tags
def simplified_line_pattern(self) -> Sequence[float]:
"""Returns the simplified dash-gap-dash... line pattern,
a dash-length of 0 represents a point. Complex line types including text
or shapes are not supported and return a continuous line pattern.
"""
return self.pattern_tags.compile()
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
assert self.doc is not None, "LTYPE entity must be assigned to a document"
super().register_resources(registry)
# register text styles and shape files for complex linetypes
style_handle = self.pattern_tags.get_style_handle()
style = self.doc.styles.get_entry_by_handle(style_handle)
if style is not None:
registry.add_entity(style)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate registered resources from self to the copied entity."""
assert isinstance(clone, Linetype)
super().map_resources(clone, mapping)
style_handle = self.pattern_tags.get_style_handle()
if style_handle != "0":
# map text style or shape file handle of complex linetype
clone.pattern_tags.set_style_handle(mapping.get_handle(style_handle))
@@ -0,0 +1,539 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Tuple,
Sequence,
Iterable,
Union,
Iterator,
Optional,
)
from typing_extensions import TypeAlias, Self
import array
import copy
from contextlib import contextmanager
from ezdxf.math import Vec3, Matrix44, Z_AXIS
from ezdxf.math.transformtools import OCSTransform, NonUniformScalingError
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import (
SUBCLASS_MARKER,
DXF2000,
LWPOLYLINE_CLOSED,
DXFStructureError,
)
from ezdxf.lldxf.tags import Tags
from ezdxf.lldxf.types import DXFTag, DXFVertex
from ezdxf.lldxf.packedtags import VertexArray
from ezdxf.render.polyline import virtual_lwpolyline_entities
from ezdxf.explode import explode_entity
from ezdxf.query import EntityQuery
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity
from .factory import register_entity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace, Line, Arc, DXFEntity
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.layouts import BaseLayout
__all__ = ["LWPolyline", "FORMAT_CODES"]
LWPointType: TypeAlias = Tuple[float, float, float, float, float]
FORMAT_CODES = frozenset("xysebv")
DEFAULT_FORMAT = "xyseb"
LWPOINTCODES = (10, 20, 40, 41, 42)
# Order does matter:
# If tag 90 is not the first TAG, AutoCAD does not close the polyline, when the
# `close` flag is set.
acdb_lwpolyline = DefSubclass(
"AcDbPolyline",
{
# Count always returns the actual length:
"count": DXFAttr(90, xtype=XType.callback, getter="__len__"),
# Elevation: OCS z-axis value for all vertices:
"elevation": DXFAttr(38, default=0, optional=True),
# Thickness can be negative!
"thickness": DXFAttr(39, default=0, optional=True),
# Flags:
# 1 = Closed
# 128 = Plinegen
"flags": DXFAttr(70, default=0),
# Const width: DXF reference error - AutoCAD uses just const width if not 0,
# for all line segments.
"const_width": DXFAttr(43, default=0, optional=True),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# 10, 20 : Vertex x, y
# 91: vertex identifier ???
# 40, 41, 42: start width, end width, bulge
},
)
acdb_lwpolyline_group_codes = group_code_mapping(acdb_lwpolyline)
@register_entity
class LWPolyline(DXFGraphic):
"""DXF LWPOLYLINE entity"""
DXFTYPE = "LWPOLYLINE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_lwpolyline)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
def __init__(self):
super().__init__()
self.lwpoints = LWPolylinePoints()
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy lwpoints."""
assert isinstance(entity, LWPolyline)
entity.lwpoints = copy.deepcopy(self.lwpoints)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
"""
Adds subclass processing for AcDbPolyline, requires previous base class
and AcDbEntity processing by parent class.
"""
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.subclass_by_index(2)
if tags:
tags = self.load_vertices(tags)
processor.fast_load_dxfattribs(
dxf,
acdb_lwpolyline_group_codes,
subclass=tags,
recover=True,
)
else:
raise DXFStructureError(
f"missing 'AcDbPolyline' subclass in LWPOLYLINE(#{dxf.handle})"
)
return dxf
def load_vertices(self, tags: Tags) -> Tags:
self.lwpoints, unprocessed_tags = LWPolylinePoints.from_tags(tags)
return unprocessed_tags
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
# Returns True if entity should be exported
# Do not export polylines without vertices
return len(self.lwpoints) > 0
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_lwpolyline.name)
self.dxf.export_dxf_attribs(
tagwriter,
["count", "flags", "const_width", "elevation", "thickness"],
)
tagwriter.write_tags(Tags(self.lwpoints.dxftags()))
self.dxf.export_dxf_attribs(tagwriter, "extrusion")
@property
def closed(self) -> bool:
"""Get/set closed state of polyline. A closed polyline has a connection
segment from the last vertex to the first vertex.
"""
return self.get_flag_state(LWPOLYLINE_CLOSED)
@closed.setter
def closed(self, status: bool) -> None:
self.set_flag_state(LWPOLYLINE_CLOSED, status)
@property
def is_closed(self) -> bool:
"""Get closed state of LWPOLYLINE.
Compatibility interface to :class:`Polyline`
"""
return self.get_flag_state(LWPOLYLINE_CLOSED)
def close(self, state: bool = True) -> None:
"""Set closed state of LWPOLYLINE.
Compatibility interface to :class:`Polyline`
"""
self.closed = state
@property
def has_arc(self) -> bool:
"""Returns ``True`` if LWPOLYLINE has an arc segment."""
return any(bool(b) for x, y, s, e, b in self.lwpoints)
@property
def has_width(self) -> bool:
"""Returns ``True`` if LWPOLYLINE has any segment with width attributes
or the DXF attribute const_width is not 0.
"""
if self.dxf.hasattr("const_width"):
# 'const_width' overrides all individual start- or end width settings.
# The DXF reference claims the opposite, but that is simply not true.
return self.dxf.const_width != 0.0
return any((s or e) for x, y, s, e, b in self.lwpoints)
def __len__(self) -> int:
"""Returns count of polyline points."""
return len(self.lwpoints)
def __iter__(self) -> Iterator[LWPointType]:
"""Returns iterable of tuples (x, y, start_width, end_width, bulge)."""
return iter(self.lwpoints)
def __getitem__(self, index: int) -> LWPointType:
"""Returns point at position `index` as (x, y, start_width, end_width,
bulge) tuple. start_width, end_width and bulge is 0 if not present,
supports extended slicing. Point format is fixed as "xyseb".
All coordinates in :ref:`OCS`.
"""
return self.lwpoints[index]
def __setitem__(self, index: int, value: Sequence[float]) -> None:
"""
Set point at position `index` as (x, y, [start_width, [end_width,
[bulge]]]) tuple. If start_width or end_width is 0 or left off the
default width value is used. If the bulge value is left off, bulge is 0
by default (straight line).
Does NOT support extend slicing. Point format is fixed as "xyseb".
All coordinates in :ref:`OCS`.
Args:
index: point index
value: point value as (x, y, [start_width, [end_width, [bulge]]]) tuple
"""
self.lwpoints[index] = compile_array(value)
def __delitem__(self, index: int) -> None:
"""Delete point at position `index`, supports extended slicing."""
del self.lwpoints[index]
def vertices(self) -> Iterator[tuple[float, float]]:
"""
Returns iterable of all polyline points as (x, y) tuples in :ref:`OCS`
(:attr:`dxf.elevation` is the z-axis value).
"""
for point in self:
yield point[0], point[1]
def vertices_in_wcs(self) -> Iterator[Vec3]:
"""Returns iterable of all polyline points as Vec3(x, y, z) in :ref:`WCS`."""
ocs = self.ocs()
elevation = self.get_dxf_attrib("elevation", default=0.0)
for x, y in self.vertices():
yield ocs.to_wcs(Vec3(x, y, elevation))
def vertices_in_ocs(self) -> Iterator[Vec3]:
"""Returns iterable of all polyline points as Vec3(x, y, z) in :ref:`OCS`."""
elevation = self.get_dxf_attrib("elevation", default=0.0)
for x, y in self.vertices():
yield Vec3(x, y, elevation)
def append(self, point: Sequence[float], format: str = DEFAULT_FORMAT) -> None:
"""Append `point` to polyline, `format` specifies a user defined
point format.
All coordinates in :ref:`OCS`.
Args:
point: (x, y, [start_width, [end_width, [bulge]]]) tuple
format: format string, default is "xyseb", see: `format codes`_
"""
self.lwpoints.append(point, format=format)
def insert(
self, pos: int, point: Sequence[float], format: str = DEFAULT_FORMAT
) -> None:
"""Insert new point in front of positions `pos`, `format` specifies a
user defined point format.
All coordinates in :ref:`OCS`.
Args:
pos: insert position
point: point data
format: format string, default is "xyseb", see: `format codes`_
"""
data = compile_array(point, format=format)
self.lwpoints.insert(pos, data)
def append_points(
self, points: Iterable[Sequence[float]], format: str = DEFAULT_FORMAT
) -> None:
"""
Append new `points` to polyline, `format` specifies a user defined
point format.
All coordinates in :ref:`OCS`.
Args:
points: iterable of point, point is (x, y, [start_width, [end_width,
[bulge]]]) tuple
format: format string, default is "xyseb", see: `format codes`_
"""
for point in points:
self.lwpoints.append(point, format=format)
@contextmanager
def points(self, format: str = DEFAULT_FORMAT) -> Iterator[list[Sequence[float]]]:
"""Context manager for polyline points. Returns a standard Python list
of points, according to the format string.
All coordinates in :ref:`OCS`.
Args:
format: format string, see `format codes`_
"""
points = self.get_points(format=format)
yield points
self.set_points(points, format=format)
def get_points(self, format: str = DEFAULT_FORMAT) -> list[Sequence[float]]:
"""Returns all points as list of tuples, format specifies a user
defined point format.
All points in :ref:`OCS` as (x, y) tuples (:attr:`dxf.elevation` is
the z-axis value).
Args:
format: format string, default is "xyseb", see `format codes`_
"""
return [format_point(p, format=format) for p in self.lwpoints]
def set_points(
self, points: Iterable[Sequence[float]], format: str = DEFAULT_FORMAT
) -> None:
"""Remove all points and append new `points`.
All coordinates in :ref:`OCS`.
Args:
points: iterable of point, point is (x, y, [start_width, [end_width,
[bulge]]]) tuple
format: format string, default is "xyseb", see `format codes`_
"""
self.lwpoints.clear()
self.append_points(points, format=format)
def clear(self) -> None:
"""Remove all points."""
self.lwpoints.clear()
def transform(self, m: Matrix44) -> LWPolyline:
"""Transform the LWPOLYLINE entity by transformation matrix `m` inplace.
A non-uniform scaling is not supported if the entity contains circular
arc segments (bulges).
Args:
m: transformation :class:`~ezdxf.math.Matrix44`
Raises:
NonUniformScalingError: for non-uniform scaling of entity containing
circular arc segments (bulges)
"""
dxf = self.dxf
ocs = OCSTransform(self.dxf.extrusion, m)
if not ocs.scale_uniform and self.has_arc:
raise NonUniformScalingError(
"LWPOLYLINE containing arcs (bulges) does not support non uniform scaling"
)
# The caller function has to catch this exception and explode the
# LWPOLYLINE into LINE and ELLIPSE entities.
vertices = list(ocs.transform_vertex(v) for v in self.vertices_in_ocs())
lwpoints = []
for v, p in zip(vertices, self.lwpoints):
_, _, start_width, end_width, bulge = p
# assume a uniform scaling!
start_width = ocs.transform_width(start_width)
end_width = ocs.transform_width(end_width)
lwpoints.append((v.x, v.y, start_width, end_width, bulge))
self.set_points(lwpoints)
# All new OCS vertices must have the same z-axis, which is the elevation
# of the polyline:
if vertices:
dxf.elevation = vertices[0].z
if dxf.hasattr("const_width"): # assume a uniform scaling!
dxf.const_width = ocs.transform_width(dxf.const_width)
if dxf.hasattr("thickness"):
dxf.thickness = ocs.transform_thickness(dxf.thickness)
dxf.extrusion = ocs.new_extrusion
self.post_transform(m)
return self
def virtual_entities(self) -> Iterator[Union[Line, Arc]]:
"""Yields the graphical representation of LWPOLYLINE as virtual DXF
primitives (LINE or ARC).
These virtual entities are located at the original location, but are not
stored in the entity database, have no handle and are not assigned to
any layout.
"""
for e in virtual_lwpolyline_entities(self):
e.set_source_of_copy(self)
yield e
def explode(self, target_layout: Optional[BaseLayout] = None) -> EntityQuery:
"""Explode the LWPOLYLINE entity as DXF primitives (LINE or ARC) into
the target layout, if the target layout is ``None``, the target layout
is the layout of the source entity. This method destroys the source entity.
Returns an :class:`~ezdxf.query.EntityQuery` container referencing all DXF
primitives.
Args:
target_layout: target layout for the DXF primitives, ``None`` for
same layout as the source entity.
"""
return explode_entity(self, target_layout)
class LWPolylinePoints(VertexArray):
__slots__ = ("values",)
VERTEX_CODE = 10
START_WIDTH_CODE = 40
END_WIDTH_CODE = 41
BULGE_CODE = 42
VERTEX_SIZE = 5
@classmethod
def from_tags(cls, tags: Iterable[DXFTag]) -> tuple[Self, Tags]: # type: ignore
"""Setup point array from tags."""
def build_vertex(point: list[float]) -> list[float]:
point.append(attribs.get(cls.START_WIDTH_CODE, 0))
point.append(attribs.get(cls.END_WIDTH_CODE, 0))
point.append(attribs.get(cls.BULGE_CODE, 0))
return point
unprocessed_tags = Tags()
vertices: list[Sequence[float]]= []
point: list[float] | None = None
attribs: dict[int, float]= {}
for tag in tags:
if tag.code in LWPOINTCODES:
if tag.code == 10:
if point is not None:
vertices.append(build_vertex(point))
# just use x- and y-axis
point = list(tag.value[0:2])
attribs = {}
else:
attribs[tag.code] = tag.value
else:
unprocessed_tags.append(tag)
if point is not None:
vertices.append(build_vertex(point))
return cls(data=vertices), unprocessed_tags
def append(self, point: Sequence[float], format: str = DEFAULT_FORMAT) -> None:
super().append(compile_array(point, format=format))
def dxftags(self) -> Iterator[DXFTag]:
for point in self:
x, y, start_width, end_width, bulge = point
yield DXFVertex(self.VERTEX_CODE, (x, y))
if start_width or end_width:
# Export always start- and end width together,
# required for BricsCAD but not AutoCAD!
yield DXFTag(self.START_WIDTH_CODE, start_width)
yield DXFTag(self.END_WIDTH_CODE, end_width)
if bulge:
yield DXFTag(self.BULGE_CODE, bulge)
def format_point(point: Sequence[float], format: str = "xyseb") -> Sequence[float]:
"""Reformat point components.
Format codes:
- ``x`` = x-coordinate
- ``y`` = y-coordinate
- ``s`` = start width
- ``e`` = end width
- ``b`` = bulge value
- ``v`` = (x, y) as tuple
Args:
point: list or tuple of (x, y, start_width, end_width, bulge)
format: format string, default is "xyseb"
Returns:
Sequence[float]: tuple of selected components
"""
x, y, s, e, b = point
v = (x, y)
vars = locals()
return tuple(vars[code] for code in format.lower() if code in FORMAT_CODES)
def compile_array(data: Sequence[float], format="xyseb") -> array.array:
"""Gather point components from input data.
Format codes:
- ``x`` = x-coordinate
- ``y`` = y-coordinate
- ``s`` = start width
- ``e`` = end width
- ``b`` = bulge value
- ``v`` = (x, y [,z]) tuple (z-axis is ignored)
Args:
data: list or tuple of point components
format: format string, default is "xyseb"
Returns:
array.array: array.array('d', (x, y, start_width, end_width, bulge))
"""
a = array.array("d", (0.0, 0.0, 0.0, 0.0, 0.0))
format = [code for code in format.lower() if code in FORMAT_CODES]
for code, value in zip(format, data):
if code not in FORMAT_CODES:
continue
if code == "v":
vertex = Vec3(value)
a[0] = vertex.x
a[1] = vertex.y
else:
a["xyseb".index(code)] = value
return a
@@ -0,0 +1,432 @@
# Copyright (c) 2018-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing_extensions import Self
from ezdxf.lldxf.const import SUBCLASS_MARKER
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
group_code_mapping,
)
from ezdxf.lldxf.tags import Tags
from .dxfentity import base_class, SubclassProcessor
from .dxfobj import DXFObject
from .factory import register_entity
from .objectcollection import ObjectCollection
from ezdxf.math import Matrix44
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace, DXFEntity
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.document import Drawing
__all__ = ["Material", "MaterialCollection"]
def fetch_matrix(tags: Tags, code: int) -> tuple[Tags, Optional[Matrix44]]:
values = []
remaining = Tags()
for tag in tags:
if tag.code == code:
values.append(tag.value)
if len(values) == 16:
# enough values collected, code 43 is maybe used for two matrices
code = -1
else:
remaining.append(tag)
if len(values) == 16:
# only if valid matrix
return remaining, Matrix44(values)
else:
return tags, None
def export_matrix(tagwriter: AbstractTagWriter, code: int, matrix: Matrix44) -> None:
if matrix is not None:
for value in matrix:
tagwriter.write_tag2(code, value)
acdb_material = DefSubclass(
"AcDbMaterial",
{
"name": DXFAttr(1),
"description": DXFAttr(2, default=""),
"ambient_color_method": DXFAttr(
70, default=0
), # 0=use current color; 1=override current color
"ambient_color_factor": DXFAttr(40, default=1.0), # valid range is 0.0 to 1.0
"ambient_color_value": DXFAttr(90), # integer representing an AcCmEntityColor
"diffuse_color_method": DXFAttr(
71, default=0
), # 0=use current color; 1=override current color
"diffuse_color_factor": DXFAttr(41, default=1.0), # valid range is 0.0 to 1.0
"diffuse_color_value": DXFAttr(
91, default=-1023410177
), # integer representing an AcCmEntityColor
"diffuse_map_blend_factor": DXFAttr(
42, default=1.0
), # valid range is 0.0 to 1.0
"diffuse_map_source": DXFAttr(72, default=1),
# 0=use current scene; 1=use image file (specified by file name; null file name specifies no map)
"diffuse_map_file_name": DXFAttr(3, default=""),
"diffuse_map_projection_method": DXFAttr(
73, default=1
), # 1=Planar; 2=Box; 3=Cylinder; 4=Sphere
"diffuse_map_tiling_method": DXFAttr(74, default=1), # 1=Tile; 2=Crop; 3=Clamp
"diffuse_map_auto_transform_method": DXFAttr(75, default=1), # bitset;
# 1 = No auto transform
# 2 = Scale mapper to current entity extents; translate mapper to entity origin
# 4 = Include current block transform in mapper transform
# 16x group code 43: Transform matrix of diffuse map mapper (16 reals; row major format; default = identity matrix)
"specular_gloss_factor": DXFAttr(44, default=0.5), # valid range is 0.0 to 1.0
"specular_color_method": DXFAttr(
73, default=0
), # 0=use current color; 1=override current color
"specular_color_factor": DXFAttr(45, default=1.0), # valid range is 0.0 to 1.0
"specular_color_value": DXFAttr(92), # integer representing an AcCmEntityColor
"specular_map_blend_factor": DXFAttr(
46, default=1.0
), # valid range is 0.0 to 1.0
"specular_map_source": DXFAttr(77, default=1),
# 0=use current scene; 1=use image file (specified by file name; null file name specifies no map)
"specular_map_file_name": DXFAttr(4, default=""),
"specular_map_projection_method": DXFAttr(
78, default=1
), # 1=Planar; 2=Box; 3=Cylinder; 4=Sphere
"specular_map_tiling_method": DXFAttr(79, default=1), # 1=Tile; 2=Crop; 3=Clamp
"specular_map_auto_transform_method": DXFAttr(170, default=1), # bitset;
# 1 = No auto transform
# 2 = Scale mapper to current entity extents; translate mapper to entity origin
# 4 = Include current block transform in mapper transform
# 16x group code 47: Transform matrix of specular map mapper (16 reals; row major format; default = identity matrix)
"reflection_map_blend_factor": DXFAttr(
48, default=1.0
), # valid range is 0.0 to 1.0
"reflection_map_source": DXFAttr(171, default=1),
# 0=use current scene; 1=use image file (specified by file name; null file name specifies no map)
"reflection_map_file_name": DXFAttr(6, default=""),
"reflection_map_projection_method": DXFAttr(
172, default=1
), # 1=Planar; 2=Box; 3=Cylinder; 4=Sphere
"reflection_map_tiling_method": DXFAttr(
173, default=1
), # 1=Tile; 2=Crop; 3=Clamp
"reflection_map_auto_transform_method": DXFAttr(174, default=1), # bitset;
# 1 = No auto transform
# 2 = Scale mapper to current entity extents; translate mapper to entity origin
# 4 = Include current block transform in mapper transform
# 16x group code 49: Transform matrix of reflection map mapper (16 reals; row major format; default = identity matrix)
"opacity": DXFAttr(140, default=1.0), # valid range is 0.0 to 1.0
"opacity_map_blend_factor": DXFAttr(
141, default=1.0
), # valid range is 0.0 to 1.0
"opacity_map_source": DXFAttr(175, default=1),
# 0=use current scene; 1=use image file (specified by file name; null file name specifies no map)
"opacity_map_file_name": DXFAttr(7, default=""),
"opacity_map_projection_method": DXFAttr(
176, default=1
), # 1=Planar; 2=Box; 3=Cylinder; 4=Sphere
"opacity_map_tiling_method": DXFAttr(177, default=1), # 1=Tile; 2=Crop; 3=Clamp
"opacity_map_auto_transform_method": DXFAttr(178, default=1), # bitset;
# 1 = No auto transform
# 2 = Scale mapper to current entity extents; translate mapper to entity origin
# 4 = Include current block transform in mapper transform
# 16x group code 142: Transform matrix of reflection map mapper (16 reals; row major format; default = identity matrix)
"bump_map_blend_factor": DXFAttr(143, default=1.0), # valid range is 0.0 to 1.0
"bump_map_source": DXFAttr(179, default=1),
# 0=use current scene; 1=use image file (specified by file name; null file name specifies no map)
"bump_map_file_name": DXFAttr(8, default=""),
"bump_map_projection_method": DXFAttr(
270, default=1
), # 1=Planar; 2=Box; 3=Cylinder; 4=Sphere
"bump_map_tiling_method": DXFAttr(271, default=1), # 1=Tile; 2=Crop; 3=Clamp
"bump_map_auto_transform_method": DXFAttr(272, default=1), # bitset;
# 1 = No auto transform
# 2 = Scale mapper to current entity extents; translate mapper to entity origin
# 4 = Include current block transform in mapper transform
# 16x group code 144: Transform matrix of bump map mapper (16 reals; row major format; default = identity matrix)
"refraction_index": DXFAttr(145, default=1.0), # valid range is 0.0 to 1.0
"refraction_map_blend_factor": DXFAttr(
146, default=1.0
), # valid range is 0.0 to 1.0
"refraction_map_source": DXFAttr(273, default=1),
# 0=use current scene; 1=use image file (specified by file name; null file name specifies no map)
"refraction_map_file_name": DXFAttr(9, default=""),
"refraction_map_projection_method": DXFAttr(
274, default=1
), # 1=Planar; 2=Box; 3=Cylinder; 4=Sphere
"refraction_map_tiling_method": DXFAttr(
275, default=1
), # 1=Tile; 2=Crop; 3=Clamp
"refraction_map_auto_transform_method": DXFAttr(276, default=1), # bitset;
# 1 = No auto transform
# 2 = Scale mapper to current entity extents; translate mapper to entity origin
# 4 = Include current block transform in mapper transform
# 16x group code 147: Transform matrix of reflection map mapper (16 reals; row major format; default = identity matrix)
# normal map shares group codes with diffuse map
"normal_map_method": DXFAttr(271),
"normal_map_strength": DXFAttr(465),
"normal_map_blend_factor": DXFAttr(
42, default=1.0
), # valid range is 0.0 to 1.0
"normal_map_source": DXFAttr(72, default=1),
# 0=use current scene; 1=use image file (specified by file name; null file name specifies no map)
"normal_map_file_name": DXFAttr(3, default=""),
"normal_map_projection_method": DXFAttr(
73, default=1
), # 1=Planar; 2=Box; 3=Cylinder; 4=Sphere
"normal_map_tiling_method": DXFAttr(74, default=1), # 1=Tile; 2=Crop; 3=Clamp
"normal_map_auto_transform_method": DXFAttr(75, default=1), # bitset;
# 1 = No auto transform
# 2 = Scale mapper to current entity extents; translate mapper to entity origin
# 4 = Include current block transform in mapper transform
# 16x group code 43: Transform matrix of reflection map mapper (16 reals; row major format; default = identity matrix)
"color_bleed_scale": DXFAttr(460),
"indirect_dump_scale": DXFAttr(461),
"reflectance_scale": DXFAttr(462),
"transmittance_scale": DXFAttr(463),
"two_sided_material": DXFAttr(290),
"luminance": DXFAttr(464),
"luminance_mode": DXFAttr(270), # multiple usage of group code 270
"materials_anonymous": DXFAttr(293),
"global_illumination_mode": DXFAttr(272),
"final_gather_mode": DXFAttr(273),
"gen_proc_name": DXFAttr(300),
"gen_proc_val_bool": DXFAttr(291),
"gen_proc_val_int": DXFAttr(271),
"gen_proc_val_real": DXFAttr(469),
"gen_proc_val_text": DXFAttr(301),
"gen_proc_table_end": DXFAttr(292),
"gen_proc_val_color_index": DXFAttr(62),
"gen_proc_val_color_rgb": DXFAttr(420),
"gen_proc_val_color_name": DXFAttr(430),
"map_utile": DXFAttr(270), # multiple usage of group code 270
"translucence": DXFAttr(148),
"self_illumination": DXFAttr(90),
"reflectivity": DXFAttr(468),
"illumination_model": DXFAttr(93),
"channel_flags": DXFAttr(94, default=63),
},
)
acdb_material_group_codes = group_code_mapping(acdb_material)
@register_entity
class Material(DXFObject):
DXFTYPE = "MATERIAL"
DEFAULT_ATTRIBS = {
"diffuse_color_method": 1,
"diffuse_color_value": -1023410177,
}
DXFATTRIBS = DXFAttributes(base_class, acdb_material)
def __init__(self) -> None:
super().__init__()
self.diffuse_mapper_matrix: Optional[Matrix44] = None # code 43
self.specular_mapper_matrix: Optional[Matrix44] = None # code 47
self.reflexion_mapper_matrix: Optional[Matrix44] = None # code 49
self.opacity_mapper_matrix: Optional[Matrix44] = None # group 142
self.bump_mapper_matrix: Optional[Matrix44] = None # group 144
self.refraction_mapper_matrix: Optional[Matrix44] = None # code 147
self.normal_mapper_matrix: Optional[Matrix44] = None # code 43 ???
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy material mapper matrices"""
def copy(matrix):
return None if matrix is None else matrix.copy()
assert isinstance(entity, Material)
entity.diffuse_mapper_matrix = copy(self.diffuse_mapper_matrix)
entity.specular_mapper_matrix = copy(self.specular_mapper_matrix)
entity.reflexion_mapper_matrix = copy(self.reflexion_mapper_matrix)
entity.opacity_mapper_matrix = copy(self.opacity_mapper_matrix)
entity.bump_mapper_matrix = copy(self.bump_mapper_matrix)
entity.refraction_mapper_matrix = copy(self.refraction_mapper_matrix)
entity.normal_mapper_matrix = copy(self.normal_mapper_matrix)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.fast_load_dxfattribs(
dxf, acdb_material_group_codes, 1, log=False
)
self.load_matrices(tags)
return dxf
def load_matrices(self, tags):
tags, matrix = fetch_matrix(tags, 43)
if matrix:
self.diffuse_mapper_matrix = matrix
tags, matrix = fetch_matrix(tags, 47)
if matrix:
self.specular_mapper_matrix = matrix
tags, matrix = fetch_matrix(tags, 49)
if matrix:
self.reflexion_mapper_matrix = matrix
tags, matrix = fetch_matrix(tags, 142)
if matrix:
self.opacity_mapper_matrix = matrix
tags, matrix = fetch_matrix(tags, 144)
if matrix:
self.bump_mapper_matrix = matrix
tags, matrix = fetch_matrix(tags, 147)
if matrix:
self.refraction_mapper_matrix = matrix
tags, matrix = fetch_matrix(tags, 43)
if matrix:
self.normal_mapper_matrix = matrix
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_material.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"name",
"description",
"ambient_color_method",
"ambient_color_factor",
"ambient_color_value",
"diffuse_color_method",
"diffuse_color_factor",
"diffuse_color_value",
"diffuse_map_blend_factor",
"diffuse_map_source",
"diffuse_map_file_name",
"diffuse_map_projection_method",
"diffuse_map_tiling_method",
"diffuse_map_auto_transform_method",
],
)
export_matrix(tagwriter, 43, self.diffuse_mapper_matrix)
self.dxf.export_dxf_attribs(
tagwriter,
[
"specular_gloss_factor",
"specular_color_method",
"specular_color_factor",
"specular_color_value",
"specular_map_blend_factor",
"specular_map_source",
"specular_map_file_name",
"specular_map_projection_method",
"specular_map_tiling_method",
"specular_map_auto_transform_method",
],
)
export_matrix(tagwriter, 47, self.specular_mapper_matrix)
self.dxf.export_dxf_attribs(
tagwriter,
[
"reflection_map_blend_factor",
"reflection_map_source",
"reflection_map_file_name",
"reflection_map_projection_method",
"reflection_map_tiling_method",
"reflection_map_auto_transform_method",
],
)
export_matrix(tagwriter, 49, self.reflexion_mapper_matrix)
self.dxf.export_dxf_attribs(
tagwriter,
[
"opacity",
"opacity_map_blend_factor",
"opacity_map_source",
"opacity_map_file_name",
"opacity_map_projection_method",
"opacity_map_tiling_method",
"opacity_map_auto_transform_method",
],
)
export_matrix(tagwriter, 142, self.opacity_mapper_matrix)
self.dxf.export_dxf_attribs(
tagwriter,
[
"bump_map_blend_factor",
"bump_map_source",
"bump_map_file_name",
"bump_map_projection_method",
"bump_map_tiling_method",
"bump_map_auto_transform_method",
],
)
export_matrix(tagwriter, 144, self.bump_mapper_matrix)
self.dxf.export_dxf_attribs(
tagwriter,
[
"refraction_index",
"refraction_map_blend_factor",
"refraction_map_source",
"refraction_map_file_name",
"refraction_map_projection_method",
"refraction_map_tiling_method",
"refraction_map_auto_transform_method",
],
)
export_matrix(tagwriter, 147, self.refraction_mapper_matrix)
self.dxf.export_dxf_attribs(
tagwriter,
[
"normal_map_method",
"normal_map_strength",
"normal_map_blend_factor",
"normal_map_source",
"normal_map_file_name",
"normal_map_projection_method",
"normal_map_tiling_method",
"normal_map_auto_transform_method",
],
)
export_matrix(tagwriter, 43, self.normal_mapper_matrix)
self.dxf.export_dxf_attribs(
tagwriter,
[
"color_bleed_scale",
"indirect_dump_scale",
"reflectance_scale",
"transmittance_scale",
"two_sided_material",
"luminance",
"luminance_mode",
"materials_anonymous",
"global_illumination_mode",
"final_gather_mode",
"gen_proc_name",
"gen_proc_val_bool",
"gen_proc_val_int",
"gen_proc_val_real",
"gen_proc_val_text",
"gen_proc_table_end",
"gen_proc_val_color_index",
"gen_proc_val_color_rgb",
"gen_proc_val_color_name",
"map_utile",
"translucence",
"self_illumination",
"reflectivity",
"illumination_model",
"channel_flags",
],
)
class MaterialCollection(ObjectCollection[Material]):
def __init__(self, doc: Drawing):
super().__init__(doc, dict_name="ACAD_MATERIAL", object_type="MATERIAL")
self.create_required_entries()
def create_required_entries(self) -> None:
for name in ("ByBlock", "ByLayer", "Global"):
if name not in self:
self.new(name)
@@ -0,0 +1,509 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Iterable,
Sequence,
Union,
Iterator,
Optional,
)
from typing_extensions import Self
import array
import copy
from itertools import chain
from contextlib import contextmanager
from ezdxf.audit import AuditError
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import (
SUBCLASS_MARKER,
DXF2000,
DXFValueError,
DXFStructureError,
DXFIndexError,
)
from ezdxf.lldxf.packedtags import VertexArray, TagArray, TagList
from ezdxf.math import Matrix44, UVec, Vec3
from ezdxf.tools import take2
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity
from .factory import register_entity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace, DXFEntity
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.lldxf.tags import Tags
from ezdxf.audit import Auditor
__all__ = ["Mesh", "MeshData"]
acdb_mesh = DefSubclass(
"AcDbSubDMesh",
{
"version": DXFAttr(71, default=2),
"blend_crease": DXFAttr(
72,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# 0 is no smoothing
"subdivision_levels": DXFAttr(
91,
default=0,
validator=validator.is_greater_or_equal_zero,
fixer=RETURN_DEFAULT,
),
# 92: Vertex count of level 0
# 10: Vertex position, multiple entries
# 93: Size of face list of level 0
# 90: Face list item, >=3 possible
# 90: length of face list
# 90: 1st vertex index
# 90: 2nd vertex index ...
# 94: Edge count of level 0
# 90: Vertex index of 1st edge
# 90: Vertex index of 2nd edge
# 95: Edge crease count of level 0
# 95 same as 94, or how is the 'edge create value' associated to edge index
# 140: Edge crease value
#
# Overriding properties: how does this work?
# 90: Count of sub-entity which property has been overridden
# 91: Sub-entity marker
# 92: Count of property was overridden
# 90: Property type
# 0 = Color
# 1 = Material
# 2 = Transparency
# 3 = Material mapper
},
)
acdb_mesh_group_codes = group_code_mapping(acdb_mesh)
class EdgeArray(TagArray):
DTYPE = "L"
def __len__(self) -> int:
return len(self.values) // 2
def __iter__(self) -> Iterator[tuple[int, int]]:
for edge in take2(self.values):
yield edge
def set_data(self, edges: Iterable[tuple[int, int]]) -> None:
self.values = array.array(self.DTYPE, chain.from_iterable(edges))
def export_dxf(self, tagwriter: AbstractTagWriter):
# count = count of edges not tags!
tagwriter.write_tag2(94, len(self.values) // 2)
for index in self.values:
tagwriter.write_tag2(90, index)
class FaceList(TagList):
def __len__(self) -> int:
return len(self.values)
def __iter__(self) -> Iterable[array.array]:
return iter(self.values)
def export_dxf(self, tagwriter: AbstractTagWriter):
# count = count of tags not faces!
tagwriter.write_tag2(93, self.tag_count())
for face in self.values:
tagwriter.write_tag2(90, len(face))
for index in face:
tagwriter.write_tag2(90, index)
def tag_count(self) -> int:
return len(self.values) + sum(len(f) for f in self.values)
def set_data(self, faces: Iterable[Sequence[int]]) -> None:
_faces = []
for face in faces:
_faces.append(face_to_array(face))
self.values = _faces
def face_to_array(face: Sequence[int]) -> array.array:
max_index = max(face)
if max_index < 256:
dtype = "B"
elif max_index < 65536:
dtype = "I"
else:
dtype = "L"
return array.array(dtype, face)
def create_vertex_array(tags: Tags, start_index: int) -> VertexArray:
vertex_tags = tags.collect_consecutive_tags(codes=(10,), start=start_index)
return VertexArray(data=[t.value for t in vertex_tags])
def create_face_list(tags: Tags, start_index: int) -> FaceList:
faces = FaceList()
faces_list = faces.values
face: list[int] = []
counter = 0
for tag in tags.collect_consecutive_tags(codes=(90,), start=start_index):
if not counter:
# leading counter tag
counter = tag.value
if face:
# group code 90 = 32 bit integer
faces_list.append(face_to_array(face))
face = []
else:
# followed by count face tags
counter -= 1
face.append(tag.value)
# add last face
if face:
# group code 90 = 32 bit integer
faces_list.append(face_to_array(face))
return faces
def create_edge_array(tags: Tags, start_index: int) -> EdgeArray:
return EdgeArray(data=collect_values(tags, start_index, code=90)) # int values
def collect_values(
tags: Tags, start_index: int, code: int
) -> Iterable[Union[float, int]]:
values = tags.collect_consecutive_tags(codes=(code,), start=start_index)
return (t.value for t in values)
def create_crease_array(tags: Tags, start_index: int) -> array.array:
return array.array("f", collect_values(tags, start_index, code=140)) # float values
COUNT_ERROR_MSG = "'MESH (#{}) without {} count.'"
@register_entity
class Mesh(DXFGraphic):
"""DXF MESH entity"""
DXFTYPE = "MESH"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_mesh)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
def __init__(self):
super().__init__()
self._vertices = VertexArray() # vertices stored as array.array('d')
self._faces = FaceList() # face lists data
self._edges = EdgeArray() # edge indices stored as array.array('L')
self._creases = array.array("f") # creases stored as array.array('f')
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy data: vertices, faces, edges, creases."""
assert isinstance(entity, Mesh)
entity._vertices = copy.deepcopy(self._vertices)
entity._faces = copy.deepcopy(self._faces)
entity._edges = copy.deepcopy(self._edges)
entity._creases = copy.deepcopy(self._creases)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.subclass_by_index(2)
if tags:
# Load mesh data and remove their tags from subclass
self.load_mesh_data(tags, dxf.handle)
# Load remaining data into name space
processor.fast_load_dxfattribs(
dxf, acdb_mesh_group_codes, 2, recover=True
)
else:
raise DXFStructureError(
f"missing 'AcDbSubMesh' subclass in MESH(#{dxf.handle})"
)
return dxf
def load_mesh_data(self, mesh_tags: Tags, handle: str) -> None:
def process_vertices():
try:
vertex_count_index = mesh_tags.tag_index(92)
except DXFValueError:
raise DXFStructureError(COUNT_ERROR_MSG.format(handle, "vertex"))
vertices = create_vertex_array(mesh_tags, vertex_count_index + 1)
# Remove vertex count tag and all vertex tags
end_index = vertex_count_index + 1 + len(vertices)
del mesh_tags[vertex_count_index:end_index]
return vertices
def process_faces():
try:
face_count_index = mesh_tags.tag_index(93)
except DXFValueError:
raise DXFStructureError(COUNT_ERROR_MSG.format(handle, "face"))
else:
# Remove face count tag and all face tags
faces = create_face_list(mesh_tags, face_count_index + 1)
end_index = face_count_index + 1 + faces.tag_count()
del mesh_tags[face_count_index:end_index]
return faces
def process_edges():
try:
edge_count_index = mesh_tags.tag_index(94)
except DXFValueError:
raise DXFStructureError(COUNT_ERROR_MSG.format(handle, "edge"))
else:
edges = create_edge_array(mesh_tags, edge_count_index + 1)
# Remove edge count tag and all edge tags
end_index = edge_count_index + 1 + len(edges.values)
del mesh_tags[edge_count_index:end_index]
return edges
def process_creases():
try:
crease_count_index = mesh_tags.tag_index(95)
except DXFValueError:
raise DXFStructureError(COUNT_ERROR_MSG.format(handle, "crease"))
else:
creases = create_crease_array(mesh_tags, crease_count_index + 1)
# Remove crease count tag and all crease tags
end_index = crease_count_index + 1 + len(creases)
del mesh_tags[crease_count_index:end_index]
return creases
self._vertices = process_vertices()
self._faces = process_faces()
self._edges = process_edges()
self._creases = process_creases()
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
"""Pre requirement check and pre-processing for export.
Returns ``False`` if entity should not be exported at all.
- MESH without vertices creates an invalid DXF file for AutoCAD and BricsCAD.
- MESH without faces creates an invalid DXF file for AutoCAD and BricsCAD.
(internal API)
"""
if len(self.vertices) == 0 or len(self.faces) == 0:
return False
return True
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_mesh.name)
self.dxf.export_dxf_attribs(
tagwriter, ["version", "blend_crease", "subdivision_levels"]
)
self.export_mesh_data(tagwriter)
self.export_override_data(tagwriter)
def export_mesh_data(self, tagwriter: AbstractTagWriter):
tagwriter.write_tag2(92, len(self.vertices))
self._vertices.export_dxf(tagwriter, code=10)
self._faces.export_dxf(tagwriter)
self._edges.export_dxf(tagwriter)
creases = self._fixed_crease_values()
tagwriter.write_tag2(95, len(self.creases))
for crease_value in creases:
tagwriter.write_tag2(140, crease_value)
def _fixed_crease_values(self) -> list[float]:
# The edge count has to match the crease count, otherwise its an invalid
# DXF file to AutoCAD!
edge_count = len(self._edges)
creases = list(self.creases)
crease_count = len(creases)
if edge_count < crease_count:
creases = creases[:edge_count]
while edge_count > len(creases):
creases.append(0.0)
return creases
def export_override_data(self, tagwriter: AbstractTagWriter):
tagwriter.write_tag2(90, 0)
@property
def creases(self) -> array.array:
"""Creases as :class:`array.array`. (read/write)"""
return self._creases
@creases.setter
def creases(self, values: Iterable[float]) -> None:
self._creases = array.array("f", values)
@property
def vertices(self):
"""Vertices as list like :class:`~ezdxf.lldxf.packedtags.VertexArray`.
(read/write)
"""
return self._vertices
@vertices.setter
def vertices(self, points: Iterable[UVec]) -> None:
self._vertices = VertexArray(points)
@property
def edges(self):
"""Edges as list like :class:`~ezdxf.lldxf.packedtags.TagArray`.
(read/write)
"""
return self._edges
@edges.setter
def edges(self, edges: Iterable[tuple[int, int]]) -> None:
self._edges.set_data(edges)
@property
def faces(self):
"""Faces as list like :class:`~ezdxf.lldxf.packedtags.TagList`.
(read/write)
"""
return self._faces
@faces.setter
def faces(self, faces: Iterable[Sequence[int]]) -> None:
self._faces.set_data(faces)
def get_data(self) -> MeshData:
return MeshData(self)
def set_data(self, data: MeshData) -> None:
self.vertices = data.vertices
self._faces.set_data(data.faces)
self._edges.set_data(data.edges)
self.creases = array.array("f", data.edge_crease_values)
if len(self.edges) != len(self.creases):
raise DXFValueError("count of edges must match count of creases")
@contextmanager
def edit_data(self) -> Iterator[MeshData]:
"""Context manager for various mesh data, returns a :class:`MeshData` instance.
Despite that vertices, edge and faces are accessible as packed data types, the
usage of :class:`MeshData` by context manager :meth:`edit_data` is still
recommended.
"""
data = self.get_data()
yield data
self.set_data(data)
def transform(self, m: Matrix44) -> Mesh:
"""Transform the MESH entity by transformation matrix `m` inplace."""
self._vertices.transform(m)
self.post_transform(m)
return self
def audit(self, auditor: Auditor) -> None:
if not self.is_alive:
return
super().audit(auditor)
if len(self.edges) != len(self.creases):
self.creases = self._fixed_crease_values() # type: ignore
auditor.fixed_error(
code=AuditError.INVALID_CREASE_VALUE_COUNT,
message=f"fixed invalid count of crease values in {str(self)}",
dxf_entity=self,
)
if len(self.vertices) == 0 or len(self.faces) == 0:
# A MESH without vertices or vertices creates an invalid DXF file for
# AutoCAD and BricsCAD
auditor.fixed_error(
code=AuditError.INVALID_VERTEX_COUNT,
message=f"removed {str(self)} without vertices or faces",
)
auditor.trash(self)
class MeshData:
def __init__(self, mesh: Mesh) -> None:
self.vertices: list[Vec3] = Vec3.list(mesh.vertices)
self.faces: list[Sequence[int]] = list(mesh.faces)
self.edges: list[tuple[int, int]] = list(mesh.edges)
self.edge_crease_values: list[float] = list(mesh.creases)
def add_face(self, vertices: Iterable[UVec]) -> Sequence[int]:
"""Add a face by a list of vertices."""
indices = tuple(self.add_vertex(vertex) for vertex in vertices)
self.faces.append(indices)
return indices
def add_edge_crease(self, v1: int, v2: int, crease: float):
"""Add an edge crease value, the edge is defined by the vertex indices
`v1` and `v2`.
The crease value defines the amount of subdivision that will be applied
to this edge. A crease value of the subdivision level prevents the edge from
deformation and a value of 0.0 means no protection from subdividing.
"""
if v1 < 0 or v1 > len(self.vertices):
raise DXFIndexError("vertex index `v1` out of range")
if v2 < 0 or v2 > len(self.vertices):
raise DXFIndexError("vertex index `v2` out of range")
self.edges.append((v1, v2))
self.edge_crease_values.append(crease)
def add_vertex(self, vertex: UVec) -> int:
if len(vertex) != 3:
raise DXFValueError("Parameter vertex has to be a 3-tuple (x, y, z).")
index = len(self.vertices)
self.vertices.append(Vec3(vertex))
return index
def optimize(self):
"""Reduce vertex count by merging coincident vertices."""
def merge_coincident_vertices() -> dict[int, int]:
original_vertices = [
(v.xyz, index, v) for index, v in enumerate(self.vertices)
]
original_vertices.sort()
self.vertices = []
index_map: dict[int, int] = {}
prev_vertex = sentinel = Vec3()
index = 0
for _, original_index, vertex in original_vertices:
if prev_vertex is sentinel or not vertex.isclose(prev_vertex):
index = len(self.vertices)
self.vertices.append(vertex)
index_map[original_index] = index
prev_vertex = vertex
else: # this is a coincident vertex
index_map[original_index] = index
return index_map
def remap_faces() -> None:
self.faces = remap_indices(self.faces)
def remap_edges() -> None:
self.edges = remap_indices(self.edges) # type: ignore
def remap_indices(indices: Sequence[Sequence[int]]) -> list[Sequence[int]]:
mapped_indices: list[Sequence[int]] = []
for entry in indices:
mapped_indices.append(tuple(index_map[index] for index in entry))
return mapped_indices
index_map = merge_coincident_vertices()
remap_faces()
remap_edges()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,967 @@
# Copyright (c) 2018-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Iterable,
Optional,
Sequence,
Iterator,
)
from typing_extensions import Self
from collections import OrderedDict, namedtuple
import math
from ezdxf.audit import AuditError
from ezdxf.entities.factory import register_entity
from ezdxf.lldxf import const, validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.tags import Tags, group_tags
from ezdxf.math import NULLVEC, X_AXIS, Y_AXIS, Z_AXIS, UVec, Vec3, UCS, OCS
from .dxfentity import base_class, SubclassProcessor
from .dxfobj import DXFObject
from .dxfgfx import DXFGraphic, acdb_entity
from .objectcollection import ObjectCollection
from .copy import default_copy
import logging
if TYPE_CHECKING:
from ezdxf.audit import Auditor
from ezdxf.document import Drawing
from ezdxf.entities import DXFNamespace, DXFEntity
from ezdxf.layouts import BaseLayout
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.math import Matrix44
from ezdxf.query import EntityQuery
from ezdxf import xref
__all__ = ["MLine", "MLineVertex", "MLineStyle", "MLineStyleCollection"]
logger = logging.getLogger("ezdxf")
# Usage example: CADKitSamples\Lock-Off.dxf
def filter_close_vertices(
vertices: Iterable[Vec3], abs_tol: float = 1e-12
) -> Iterable[Vec3]:
prev = None
for vertex in vertices:
if prev is None:
yield vertex
prev = vertex
else:
if not vertex.isclose(prev, abs_tol=abs_tol):
yield vertex
prev = vertex
acdb_mline = DefSubclass(
"AcDbMline",
OrderedDict(
{
"style_name": DXFAttr(2, default="Standard"),
"style_handle": DXFAttr(340),
"scale_factor": DXFAttr(
40,
default=1,
validator=validator.is_not_zero,
fixer=RETURN_DEFAULT,
),
# Justification
# 0 = Top (Right)
# 1 = Zero (Center)
# 2 = Bottom (Left)
"justification": DXFAttr(
70,
default=0,
validator=validator.is_in_integer_range(0, 3),
fixer=RETURN_DEFAULT,
),
# Flags (bit-coded values):
# 1 = Has at least one vertex (code 72 is greater than 0)
# 2 = Closed
# 4 = Suppress start caps
# 8 = Suppress end caps
"flags": DXFAttr(71, default=1),
# Number of MLINE vertices
"count": DXFAttr(72, xtype=XType.callback, getter="__len__"),
# Number of elements in MLINESTYLE definition
"style_element_count": DXFAttr(73, default=2),
# start location in WCS!
"start_location": DXFAttr(
10, xtype=XType.callback, getter="start_location"
),
# Normal vector of the entity plane, but all vertices in WCS!
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# MLine data:
# 11: vertex coordinates
# Multiple entries; one entry for each vertex.
# 12: Direction vector of segment starting at this vertex
# Multiple entries; one for each vertex.
# 13: Direction vector of miter at this vertex
# Multiple entries: one for each vertex.
# 74: Number of parameters for this element,
# repeats for each element in segment
# 41: Element parameters,
# repeats based on previous code 74
# 75: Number of area fill parameters for this element,
# repeats for each element in segment
# 42: Area fill parameters,
# repeats based on previous code 75
}
),
)
acdb_mline_group_codes = group_code_mapping(acdb_mline)
# For information about line- and fill parametrization see comments in class
# MLineVertex().
#
# The 2 group codes in mline entities and mlinestyle objects are redundant
# fields. These groups should not be modified under any circumstances, although
# it is safe to read them and use their values. The correct fields to modify
# are as follows:
#
# Mline
# The 340 group in the same object, which indicates the proper MLINESTYLE
# object.
#
# Mlinestyle
# The 3 group value in the MLINESTYLE dictionary, which precedes the 350 group
# that has the handle or entity name of
# the current mlinestyle.
# Facts and assumptions not clearly defined by the DXF reference:
# - the reference line is defined by the group code 11 points (fact)
# - all line segments are parallel to the reference line (assumption)
# - all line vertices are located in the same plane, the orientation of the plane
# is defined by the extrusion vector (assumption)
# - the scale factor is applied to to all geometries
# - the start- and end angle (MLineStyle) is also applied to the first and last
# miter direction vector
# - the last two points mean: all geometries and direction vectors can be used
# as stored in the DXF file no additional scaling or rotation is necessary
# for the MLINE rendering. Disadvantage: minor changes of DXF attributes
# require a refresh of the MLineVertices.
# Ezdxf does not support the creation of line-break (gap) features, but will be
# preserving this data if the MLINE stays unchanged.
# Editing the MLINE entity by ezdxf removes the line-break features (gaps).
class MLineVertex:
def __init__(self) -> None:
self.location: Vec3 = NULLVEC
self.line_direction: Vec3 = X_AXIS
self.miter_direction: Vec3 = Y_AXIS
# Line parametrization (74/41)
# ----------------------------
# The line parameterization is a list of float values.
# The list may contain zero or more items.
#
# The first value (miter-offset) is the distance from the vertex
# location along the miter direction vector to the point where the
# line element's path intersects the miter vector.
#
# The next value (line-start-offset) is the distance along the line
# direction from the miter/line path intersection point to the actual
# start of the line element.
#
# The next value (dash-length) is the distance from the start of the
# line element (dash) to the first break (or gap) in the line element.
# The successive values continue to list the start and stop points of
# the line element in this segment of the mline.
# Linetypes do not affect the line parametrization.
#
#
# 1. line element: [miter-offset, line-start-offset, dash, gap, dash, ...]
# 2. line element: [...]
# ...
self.line_params: list[Sequence[float]] = []
""" The line parameterization is a list of float values.
The list may contain zero or more items.
"""
# Fill parametrization (75/42)
# ----------------------------
#
# The fill parameterization is also a list of float values.
# Similar to the line parametrization, it describes the
# parametrization of the fill area for this mline segment.
# The values are interpreted identically to the line parameters and when
# taken as a whole for all line elements in the mline segment, they
# define the boundary of the fill area for the mline segment.
#
# A common example of the use of the fill mechanism is when an
# unfilled mline crosses over a filled mline and "mledit" is used to
# cause the filled mline to appear unfilled in the crossing area.
# This would result in two fill parameters for each line element in the
# affected mline segment; one for the fill stop and one for the fill
# start.
#
# [dash-length, gap-length, ...]?
self.fill_params: list[Sequence[float]] = []
def __copy__(self) -> MLineVertex:
vtx = self.__class__()
vtx.location = self.location
vtx.line_direction = self.line_direction
vtx.miter_direction = self.miter_direction
vtx.line_params = list(self.line_params)
vtx.fill_params = list(self.fill_params)
return vtx
copy = __copy__
@classmethod
def load(cls, tags: Tags) -> MLineVertex:
vtx = MLineVertex()
line_params: list[float] = []
line_params_count = 0
fill_params: list[float] = []
fill_params_count = 0
for code, value in tags:
if code == 11:
vtx.location = Vec3(value)
elif code == 12:
vtx.line_direction = Vec3(value)
elif code == 13:
vtx.miter_direction = Vec3(value)
elif code == 74:
line_params_count = value
if line_params_count == 0:
vtx.line_params.append(tuple())
else:
line_params = []
elif code == 41:
line_params.append(value)
line_params_count -= 1
if line_params_count == 0:
vtx.line_params.append(tuple(line_params))
line_params = []
elif code == 75:
fill_params_count = value
if fill_params_count == 0:
vtx.fill_params.append(tuple())
else:
fill_params = []
elif code == 42:
fill_params.append(value)
fill_params_count -= 1
if fill_params_count == 0:
vtx.fill_params.append(tuple(fill_params))
return vtx
def export_dxf(self, tagwriter: AbstractTagWriter):
tagwriter.write_vertex(11, self.location)
tagwriter.write_vertex(12, self.line_direction)
tagwriter.write_vertex(13, self.miter_direction)
for line_params, fill_params in zip(self.line_params, self.fill_params):
tagwriter.write_tag2(74, len(line_params))
for param in line_params:
tagwriter.write_tag2(41, param)
tagwriter.write_tag2(75, len(fill_params))
for param in fill_params:
tagwriter.write_tag2(42, param)
@classmethod
def new(
cls,
start: UVec,
line_direction: UVec,
miter_direction: UVec,
line_params: Optional[Iterable[Sequence[float]]] = None,
fill_params: Optional[Iterable[Sequence[float]]] = None,
) -> MLineVertex:
vtx = MLineVertex()
vtx.location = Vec3(start)
vtx.line_direction = Vec3(line_direction)
vtx.miter_direction = Vec3(miter_direction)
vtx.line_params = list(line_params or [])
vtx.fill_params = list(fill_params or [])
if len(vtx.line_params) != len(vtx.fill_params):
raise const.DXFValueError("Count mismatch of line- and fill parameters")
return vtx
def transform(self, m: Matrix44) -> MLineVertex:
"""Transform MLineVertex by transformation matrix `m` inplace."""
self.location = m.transform(self.location)
self.line_direction = m.transform_direction(self.line_direction)
self.miter_direction = m.transform_direction(self.miter_direction)
return self
@register_entity
class MLine(DXFGraphic):
DXFTYPE = "MLINE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_mline)
MIN_DXF_VERSION_FOR_EXPORT = const.DXF2000
TOP = const.MLINE_TOP
ZERO = const.MLINE_ZERO
BOTTOM = const.MLINE_BOTTOM
HAS_VERTICES = const.MLINE_HAS_VERTICES
CLOSED = const.MLINE_CLOSED
SUPPRESS_START_CAPS = const.MLINE_SUPPRESS_START_CAPS
SUPPRESS_END_CAPS = const.MLINE_SUPPRESS_END_CAPS
def __init__(self) -> None:
super().__init__()
# The MLINE geometry stored in vertices, is the final geometry,
# scaling factor, justification and MLineStyle settings are already
# applied. This is why the geometry has to be updated every time a
# change is applied.
self.vertices: list[MLineVertex] = []
def __len__(self):
"""Count of MLINE vertices."""
return len(self.vertices)
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, MLine)
entity.vertices = [v.copy() for v in self.vertices]
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.fast_load_dxfattribs(
dxf, acdb_mline_group_codes, 2, log=False
)
self.load_vertices(tags)
return dxf
def load_vertices(self, tags: Tags) -> None:
self.vertices.extend(
MLineVertex.load(tags) for tags in group_tags(tags, splitcode=11)
)
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
# Do not export MLines without vertices
return len(self.vertices) > 1
# todo: check if line- and fill parametrization is compatible with
# MLINE style, requires same count of elements!
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
# ezdxf does not export MLINE entities without vertices,
# see method preprocess_export()
self.set_flag_state(self.HAS_VERTICES, True)
super().export_entity(tagwriter)
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_mline.name)
self.dxf.export_dxf_attribs(tagwriter, acdb_mline.attribs.keys())
self.export_vertices(tagwriter)
def export_vertices(self, tagwriter: AbstractTagWriter) -> None:
for vertex in self.vertices:
vertex.export_dxf(tagwriter)
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
super().register_resources(registry)
registry.add_handle(self.dxf.style_handle)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
super().map_resources(clone, mapping)
style = mapping.get_reference_of_copy(self.dxf.style_handle)
if not isinstance(style, MLineStyle):
assert clone.doc is not None
style = clone.doc.mline_styles.get("Standard")
if isinstance(style, MLineStyle):
clone.dxf.style_handle = style.dxf.handle
clone.dxf.style_name = style.dxf.name
else:
clone.dxf.style_handle = "0"
clone.dxf.style_name = "Standard"
@property
def is_closed(self) -> bool:
"""Returns ``True`` if MLINE is closed.
Compatibility interface to :class:`Polyline`
"""
return self.get_flag_state(self.CLOSED)
def close(self, state: bool = True) -> None:
"""Get/set closed state of MLINE and update geometry accordingly.
Compatibility interface to :class:`Polyline`
"""
state = bool(state)
if state != self.is_closed:
self.set_flag_state(self.CLOSED, state)
self.update_geometry()
@property
def start_caps(self) -> bool:
"""Get/Set start caps state. ``True`` to enable start caps and
``False`` tu suppress start caps."""
return not self.get_flag_state(self.SUPPRESS_START_CAPS)
@start_caps.setter
def start_caps(self, value: bool) -> None:
"""Set start caps state."""
self.set_flag_state(self.SUPPRESS_START_CAPS, not bool(value))
@property
def end_caps(self) -> bool:
"""Get/Set end caps state. ``True`` to enable end caps and
``False`` tu suppress start caps."""
return not self.get_flag_state(self.SUPPRESS_END_CAPS)
@end_caps.setter
def end_caps(self, value: bool) -> None:
"""Set start caps state."""
self.set_flag_state(self.SUPPRESS_END_CAPS, not bool(value))
def set_scale_factor(self, value: float) -> None:
"""Set the scale factor and update geometry accordingly."""
value = float(value)
if not math.isclose(self.dxf.scale_factor, value):
self.dxf.scale_factor = value
self.update_geometry()
def set_justification(self, value: int) -> None:
"""Set MLINE justification and update geometry accordingly.
See :attr:`dxf.justification` for valid settings.
"""
value = int(value)
if self.dxf.justification != value:
self.dxf.justification = value
self.update_geometry()
@property
def style(self) -> Optional[MLineStyle]:
"""Get associated MLINESTYLE."""
if self.doc is None:
return None
_style = self.doc.entitydb.get(self.dxf.style_handle)
if _style is None:
_style = self.doc.mline_styles.get(self.dxf.style_name)
return _style # type: ignore
def set_style(self, name: str) -> None:
"""Set MLINESTYLE by name and update geometry accordingly.
The MLINESTYLE definition must exist.
"""
if self.doc is None:
logger.debug("Can't change style of unbounded MLINE entity.")
return
try:
style = self.doc.mline_styles[name]
except const.DXFKeyError:
raise const.DXFValueError(f"Undefined MLINE style: {name}")
assert isinstance(style, MLineStyle)
# Line- and fill parametrization depends on the count of
# elements, a change in the number of elements triggers a
# reset of the parametrization:
old_style = self.style
new_element_count = len(style.elements)
reset = False
if old_style is not None:
# Do not trust the stored "style_element_count" value
reset = len(old_style.elements) != new_element_count
self.dxf.style_name = name
self.dxf.style_handle = style.dxf.handle
self.dxf.style_element_count = new_element_count
if reset:
self.update_geometry()
def start_location(self) -> Vec3:
"""Returns the start location of the reference line. Callback function
for :attr:`dxf.start_location`.
"""
if len(self.vertices):
return self.vertices[0].location
else:
return NULLVEC
def get_locations(self) -> list[Vec3]:
"""Returns the vertices of the reference line."""
return [v.location for v in self.vertices]
def extend(self, vertices: Iterable[UVec]) -> None:
"""Append multiple vertices to the reference line.
It is possible to work with 3D vertices, but all vertices have to be in
the same plane and the normal vector of this plan is stored as
extrusion vector in the MLINE entity.
"""
vertices = Vec3.list(vertices)
if not vertices:
return
all_vertices = []
if len(self):
all_vertices.extend(self.get_locations())
all_vertices.extend(vertices)
self.generate_geometry(all_vertices)
def update_geometry(self) -> None:
"""Regenerate the MLINE geometry based on current settings."""
self.generate_geometry(self.get_locations())
def generate_geometry(self, vertices: list[Vec3]) -> None:
"""Regenerate the MLINE geometry for new reference line defined by
`vertices`.
"""
vertices = list(filter_close_vertices(vertices, abs_tol=1e-6))
if len(vertices) == 0:
self.clear()
return
elif len(vertices) == 1:
self.vertices = [MLineVertex.new(vertices[0], X_AXIS, Y_AXIS)]
return
style = self.style
assert style is not None, "valid MLINE style required"
if len(style.elements) == 0:
raise const.DXFStructureError(f"No line elements defined in {str(style)}.")
def miter(dir1: Vec3, dir2: Vec3):
return ((dir1 + dir2) * 0.5).normalize().orthogonal()
ucs = UCS.from_z_axis_and_point_in_xz(
origin=vertices[0],
point=vertices[1],
axis=self.dxf.extrusion,
)
# Transform given vertices into UCS and project them into the
# UCS-xy-plane by setting the z-axis to 0:
vertices = [v.replace(z=0.0) for v in ucs.points_from_wcs(vertices)]
start_angle = style.dxf.start_angle
end_angle = style.dxf.end_angle
line_directions = [
(v2 - v1).normalize() for v1, v2 in zip(vertices, vertices[1:])
]
if self.is_closed:
line_directions.append((vertices[0] - vertices[-1]).normalize())
closing_miter = miter(line_directions[0], line_directions[-1])
miter_directions = [closing_miter]
else:
closing_miter = None
line_directions.append(line_directions[-1])
miter_directions = [line_directions[0].rotate_deg(start_angle)]
for d1, d2 in zip(line_directions, line_directions[1:]):
miter_directions.append(miter(d1, d2))
if closing_miter is None:
miter_directions.pop()
miter_directions.append(line_directions[-1].rotate_deg(end_angle))
else:
miter_directions.append(closing_miter)
self.vertices = [
MLineVertex.new(v, d, m)
for v, d, m in zip(vertices, line_directions, miter_directions)
]
self._update_parametrization()
# reverse transformation into WCS
for v in self.vertices:
v.transform(ucs.matrix)
def _update_parametrization(self):
scale = self.dxf.scale_factor
style = self.style
justification = self.dxf.justification
offsets = [e.offset for e in style.elements]
min_offset = min(offsets)
max_offset = max(offsets)
shift = 0
if justification == self.TOP:
shift = -max_offset
elif justification == self.BOTTOM:
shift = -min_offset
for vertex in self.vertices:
angle = vertex.line_direction.angle_between(vertex.miter_direction)
try:
stretch = scale / math.sin(angle)
except ZeroDivisionError:
stretch = 1.0
vertex.line_params = [
((element.offset + shift) * stretch, 0.0) for element in style.elements
]
vertex.fill_params = [tuple() for _ in style.elements]
def clear(self) -> None:
"""Remove all MLINE vertices."""
self.vertices.clear()
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)
self.dxf.style_handle = "0"
if other:
style = other.mline_styles.get(self.dxf.style_name)
if style:
self.dxf.style_handle = style.dxf.handle
return
self.dxf.style_name = "Standard"
def transform(self, m: Matrix44) -> Self:
"""Transform MLINE entity by transformation matrix `m` inplace."""
for vertex in self.vertices:
vertex.transform(m)
self.dxf.extrusion = m.transform_direction(self.dxf.extrusion)
scale = self.dxf.scale_factor
scale_vec = m.transform_direction(Vec3(scale, scale, scale))
if math.isclose(scale_vec.x, scale_vec.y, abs_tol=1e-6) and math.isclose(
scale_vec.y, scale_vec.z, abs_tol=1e-6
):
self.dxf.scale_factor = sum(scale_vec) / 3 # average error
# None uniform scaling will not be applied to the scale_factor!
self.update_geometry()
self.post_transform(m)
return self
def __virtual_entities__(self) -> Iterator[DXFGraphic]:
"""Implements the SupportsVirtualEntities protocol.
This protocol is for consistent internal usage and does not replace
the method :meth:`virtual_entities`!
"""
from ezdxf.render.mline import virtual_entities
for e in virtual_entities(self):
e.set_source_of_copy(self)
yield e
def virtual_entities(self) -> Iterator[DXFGraphic]:
"""Yields virtual DXF primitives of the MLINE entity as LINE, ARC and HATCH
entities.
These entities are located at the original positions, but are not stored
in the entity database, have no handle and are not assigned to any
layout.
"""
return self.__virtual_entities__()
def explode(self, target_layout: Optional[BaseLayout] = None) -> EntityQuery:
"""Explode the MLINE entity as LINE, ARC and HATCH entities into target
layout, if target layout is ``None``, the target layout is the layout
of the MLINE. This method destroys the source entity.
Returns an :class:`~ezdxf.query.EntityQuery` container referencing all DXF
primitives.
Args:
target_layout: target layout for DXF primitives, ``None`` for same layout
as source entity.
"""
from ezdxf.explode import explode_entity
return explode_entity(self, target_layout)
def audit(self, auditor: Auditor) -> None:
"""Validity check."""
def reset_mline_style(name="Standard"):
auditor.fixed_error(
code=AuditError.RESET_MLINE_STYLE,
message=f'Reset MLINESTYLE to "{name}" in {str(self)}.',
dxf_entity=self,
)
self.dxf.style_name = name
style = doc.mline_styles.get(name)
self.dxf.style_handle = style.dxf.handle
super().audit(auditor)
doc = auditor.doc
if doc is None:
return
# Audit associated MLINESTYLE name and handle:
style = doc.entitydb.get(self.dxf.style_handle)
if not isinstance(style, MLineStyle): # handle is invalid, get style by name
style = doc.mline_styles.get(self.dxf.style_name, None)
if style is None:
reset_mline_style()
else: # fix MLINESTYLE handle:
auditor.fixed_error(
code=AuditError.INVALID_MLINESTYLE_HANDLE,
message=f"Fixed invalid style handle in {str(self)}.",
dxf_entity=self,
)
self.dxf.style_handle = style.dxf.handle
else: # update MLINESTYLE name silently
self.dxf.style_name = style.dxf.name
# Get current (maybe fixed) MLINESTYLE:
style = self.style
assert style is not None, "valid MLINE style required"
# Update style element count silently:
element_count = len(style.elements)
self.dxf.style_element_count = element_count
# Audit vertices:
for vertex in self.vertices:
if NULLVEC.isclose(vertex.line_direction):
break
if NULLVEC.isclose(vertex.miter_direction):
break
if len(vertex.line_params) != element_count:
break
# Ignore fill parameters.
else: # no break
return
# Invalid vertices found:
auditor.fixed_error(
code=AuditError.INVALID_MLINE_VERTEX,
message=f"Execute geometry update for {str(self)}.",
dxf_entity=self,
)
self.update_geometry()
def ocs(self) -> OCS:
# WCS entity which supports the "extrusion" attribute in a
# different way!
return OCS()
acdb_mline_style = DefSubclass(
"AcDbMlineStyle",
{
"name": DXFAttr(2, default="Standard"),
# Flags (bit-coded):
# 1 =Fill on
# 2 = Display miters
# 16 = Start square end (line) cap
# 32 = Start inner arcs cap
# 64 = Start round (outer arcs) cap
# 256 = End square (line) cap
# 512 = End inner arcs cap
# 1024 = End round (outer arcs) cap
"flags": DXFAttr(70, default=0),
# Style description (string, 255 characters maximum):
"description": DXFAttr(3, default=""),
# Fill color (integer, default = 256):
"fill_color": DXFAttr(
62,
default=256,
validator=validator.is_valid_aci_color,
fixer=RETURN_DEFAULT,
),
# Start angle (real, default is 90 degrees):
"start_angle": DXFAttr(51, default=90),
# End angle (real, default is 90 degrees):
"end_angle": DXFAttr(52, default=90),
# 71: Number of elements
# 49: Element offset (real, no default).
# Multiple entries can exist; one entry for each element
# 62: Element color (integer, default = 0).
# Multiple entries can exist; one entry for each element
# 6: Element linetype (string, default = BYLAYER).
# Multiple entries can exist; one entry for each element
},
)
acdb_mline_style_group_codes = group_code_mapping(acdb_mline_style)
MLineStyleElement = namedtuple("MLineStyleElement", "offset color linetype")
class MLineStyleElements:
def __init__(self, tags: Optional[Tags] = None):
self.elements: list[MLineStyleElement] = []
if tags:
for e in self.parse_tags(tags):
data = MLineStyleElement(
e.get("offset", 1.0),
e.get("color", 0),
e.get("linetype", "BYLAYER"),
)
self.elements.append(data)
def copy(self) -> MLineStyleElements:
elements = MLineStyleElements()
# new list of immutable data
elements.elements = list(self.elements)
return elements
def __len__(self):
return len(self.elements)
def __getitem__(self, item):
return self.elements[item]
def __iter__(self):
return iter(self.elements)
def export_dxf(self, tagwriter: AbstractTagWriter):
write_tag = tagwriter.write_tag2
write_tag(71, len(self.elements))
for offset, color, linetype in self.elements:
write_tag(49, offset)
write_tag(62, color)
write_tag(6, linetype)
def append(self, offset: float, color: int = 0, linetype: str = "BYLAYER") -> None:
"""Append a new line element.
Args:
offset: normal offset from the reference line: if justification is
``MLINE_ZERO``, positive values are above and negative values
are below the reference line.
color: :ref:`ACI` value
linetype: linetype name
"""
self.elements.append(
MLineStyleElement(float(offset), int(color), str(linetype))
)
@staticmethod
def parse_tags(tags: Tags) -> Iterator[dict]:
collector = None
for code, value in tags:
if code == 49:
if collector is not None:
yield collector
collector = {"offset": value}
elif code == 62:
collector["color"] = value # type: ignore
elif code == 6:
collector["linetype"] = value # type: ignore
if collector is not None:
yield collector
def ordered_indices(self) -> list[int]:
offsets = [e.offset for e in self.elements]
return [offsets.index(value) for value in sorted(offsets)]
@register_entity
class MLineStyle(DXFObject):
DXFTYPE = "MLINESTYLE"
DXFATTRIBS = DXFAttributes(base_class, acdb_mline_style)
FILL = const.MLINESTYLE_FILL
MITER = const.MLINESTYLE_MITER
START_SQUARE = const.MLINESTYLE_START_SQUARE
START_INNER_ARC = const.MLINESTYLE_START_INNER_ARC
START_ROUND = const.MLINESTYLE_START_ROUND
END_SQUARE = const.MLINESTYLE_END_SQUARE
END_INNER_ARC = const.MLINESTYLE_END_INNER_ARC
END_ROUND = const.MLINESTYLE_END_ROUND
def __init__(self):
super().__init__()
self.elements = MLineStyleElements()
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, MLineStyle)
entity.elements = self.elements.copy()
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.subclass_by_index(1)
if tags is None:
raise const.DXFStructureError(
f"missing 'AcDbMLine' subclass in MLINE(#{dxf.handle})"
)
try:
# Find index of the count tag:
index71 = tags.tag_index(71)
except const.DXFValueError:
# The count tag does not exist: DXF structure error?
pass
else:
self.elements = MLineStyleElements(tags[index71 + 1 :]) # type: ignore
# Remove processed tags:
del tags[index71:]
processor.fast_load_dxfattribs(dxf, acdb_mline_style_group_codes, tags)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_mline_style.name)
self.dxf.export_dxf_attribs(tagwriter, acdb_mline_style.attribs.keys())
self.elements.export_dxf(tagwriter)
def update_all(self):
"""Update all MLINE entities using this MLINESTYLE.
The update is required if elements were added or removed or the offset
of any element was changed.
"""
if self.doc:
handle = self.dxf.handle
mlines = (e for e in self.doc.entitydb.values() if e.dxftype() == "MLINE")
for mline in mlines:
if mline.dxf.style_handle == handle:
mline.update_geometry()
def ordered_indices(self) -> list[int]:
return self.elements.ordered_indices()
def audit(self, auditor: Auditor) -> None:
super().audit(auditor)
if len(self.elements) == 0:
auditor.add_error(
code=AuditError.INVALID_MLINESTYLE_ELEMENT_COUNT,
message=f"No line elements defined in {str(self)}.",
dxf_entity=self,
)
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
super().register_resources(registry)
for element in self.elements:
registry.add_linetype(element.linetype)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
assert isinstance(clone, MLineStyle)
super().map_resources(clone, mapping)
self.elements.elements = [
MLineStyleElement(
element.offset,
element.color,
mapping.get_linetype(element.linetype),
)
for element in self.elements
]
class MLineStyleCollection(ObjectCollection[MLineStyle]):
def __init__(self, doc: Drawing):
super().__init__(doc, dict_name="ACAD_MLINESTYLE", object_type="MLINESTYLE")
self.create_required_entries()
def create_required_entries(self) -> None:
if "Standard" not in self:
entity: MLineStyle = self.new("Standard")
entity.elements.append(0.5, 256)
entity.elements.append(-0.5, 256)
@@ -0,0 +1,241 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import logging
from ezdxf.lldxf import validator, const
from ezdxf.lldxf.attributes import (
DXFAttributes,
DefSubclass,
DXFAttr,
RETURN_DEFAULT,
XType,
group_code_mapping,
)
from ezdxf.math import NULLVEC, Z_AXIS
from .dxfentity import base_class
from .dxfgfx import acdb_entity
from .factory import register_entity
from .polygon import DXFPolygon
from .gradient import Gradient, GradientType
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.colors import RGB
__all__ = ["MPolygon"]
logger = logging.getLogger("ezdxf")
acdb_mpolygon = DefSubclass(
"AcDbMPolygon",
{
# MPolygon: version
"version": DXFAttr(
70,
default=1,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# x- and y-axis always equal 0, z-axis represents the elevation:
"elevation": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# Hatch pattern name:
"pattern_name": DXFAttr(2, default=""),
# Solid fill color as ACI
"fill_color": DXFAttr(
63,
default=const.BYLAYER,
optional=True,
validator=validator.is_valid_aci_color,
fixer=RETURN_DEFAULT,
),
# MPolygon: Solid-fill flag:
# 0 = lacks solid fill
# 1 = has solid fill
"solid_fill": DXFAttr(
71,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Hatch style tag is not supported for MPOLYGON!?
# I don't know in which order this tag has to be exported.
# TrueView does not accept this tag in any place!
# BricsCAD supports this tag!
"hatch_style": DXFAttr(
75,
default=const.HATCH_STYLE_NESTED,
validator=validator.is_in_integer_range(0, 3),
fixer=RETURN_DEFAULT,
optional=True,
),
# Hatch pattern type ... see HATCH
"pattern_type": DXFAttr(
76,
default=const.HATCH_TYPE_PREDEFINED,
validator=validator.is_in_integer_range(0, 3),
fixer=RETURN_DEFAULT,
),
# Hatch pattern angle (pattern fill only) in degrees:
"pattern_angle": DXFAttr(52, default=0),
# Hatch pattern scale or spacing (pattern fill only):
"pattern_scale": DXFAttr(
41,
default=1,
validator=validator.is_not_zero,
fixer=RETURN_DEFAULT,
),
# MPolygon: Boundary annotation flag:
# 0 = boundary is not an annotated boundary
# 1 = boundary is an annotated boundary
"annotated_boundary": DXFAttr(
73,
default=0,
optional=True,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Hatch pattern double flag (pattern fill only) .. see HATCH
"pattern_double": DXFAttr(
77,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# see ... HATCH
"pixel_size": DXFAttr(47, optional=True),
"n_seed_points": DXFAttr(
98,
default=0,
validator=validator.is_greater_or_equal_zero,
fixer=RETURN_DEFAULT,
),
# MPolygon: offset vector in OCS ???
"offset_vector": DXFAttr(11, xtype=XType.point2d, default=(0, 0)),
# MPolygon: number of degenerate boundary paths (loops), where a
# degenerate boundary path is a border that is ignored by the hatch:
"degenerated_loops": DXFAttr(99, default=0),
},
)
acdb_mpolygon_group_code = group_code_mapping(acdb_mpolygon)
@register_entity
class MPolygon(DXFPolygon):
"""DXF MPOLYGON entity
The MPOLYGON is not a core DXF entity, and requires a CLASS definition.
"""
DXFTYPE = "MPOLYGON"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_mpolygon)
MIN_DXF_VERSION_FOR_EXPORT = const.DXF2000
LOAD_GROUP_CODES = acdb_mpolygon_group_code
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
if self.paths.has_edge_paths:
logger.warning(
"MPOLYGON including edge paths are not exported by ezdxf!"
)
return False
return True
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_mpolygon.name)
dxf = self.dxf
dxf.export_dxf_attribs(
tagwriter,
[ # tag order is important!
"version",
"elevation",
"extrusion",
"pattern_name",
"solid_fill",
],
)
self.paths.export_dxf(tagwriter, self.dxftype())
# hatch_style not supported?
dxf.export_dxf_attribs(
tagwriter,
[
# "hatch_style", # not supported by MPolygon ???
"pattern_type",
],
)
if dxf.solid_fill == 0: # export pattern
dxf.export_dxf_attribs(
tagwriter, ["pattern_angle", "pattern_scale", "pattern_double"]
)
dxf.export_dxf_attribs(
tagwriter,
[
"annotated_boundary",
"pixel_size",
],
)
if dxf.solid_fill == 0: # export pattern
if self.pattern:
self.pattern.export_dxf(tagwriter, force=True)
else: # required pattern length tag!
tagwriter.write_tag2(78, 0)
if tagwriter.dxfversion > const.DXF2000:
dxf.export_dxf_attribs(tagwriter, "fill_color")
dxf.export_dxf_attribs(tagwriter, "offset_vector")
self.export_degenerated_loops(tagwriter)
self.export_gradient(tagwriter)
def export_degenerated_loops(self, tagwriter: AbstractTagWriter):
self.dxf.export_dxf_attribs(tagwriter, "degenerated_loops")
def export_gradient(self, tagwriter: AbstractTagWriter):
if tagwriter.dxfversion < const.DXF2004:
return
if self.gradient is None:
self.gradient = Gradient(kind=0, num=0, type=0)
self.gradient.export_dxf(tagwriter)
def set_solid_fill(
self, color: int = 7, style: int = 1, rgb: Optional[RGB] = None
):
"""Set :class:`MPolygon` to solid fill mode and removes all gradient and
pattern fill related data.
Args:
color: :ref:`ACI`, (0 = BYBLOCK; 256 = BYLAYER)
style: hatch style is not supported by MPOLYGON, just for symmetry
to HATCH
rgb: true color value as (r, g, b)-tuple - has higher priority
than `color`. True color support requires DXF R2004+
"""
# remove existing gradient and pattern fill
self.gradient = None
self.pattern = None
self.dxf.solid_fill = 1
# if a gradient is present, the solid fill_color is ignored
self.dxf.fill_color = color
self.dxf.pattern_name = "SOLID"
self.dxf.pattern_type = const.HATCH_TYPE_PREDEFINED
if rgb is not None:
self.set_solid_rgb_gradient(rgb)
def set_solid_rgb_gradient(self, rgb: RGB):
"""Set solid fill color as gradient of a single RGB color.
This disables pattern fill!
(internal API)
"""
self.gradient = Gradient(kind=1, num=2, type=GradientType.LINEAR)
self.gradient.color1 = rgb
self.gradient.color2 = rgb
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,154 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Iterator, Sequence
from .mtext import MText, MTextColumns
__all__ = [
"make_static_columns_r2000",
"make_dynamic_auto_height_columns_r2000",
"make_dynamic_manual_height_columns_r2000",
"make_static_columns_r2018",
"make_dynamic_auto_height_columns_r2018",
"make_dynamic_manual_height_columns_r2018",
]
COLUMN_BREAK = "\\N"
def add_column_breaks(content: Iterable[str]) -> Iterator[str]:
content = list(content)
for c in content[:-1]:
if not c.endswith(COLUMN_BREAK):
c += COLUMN_BREAK
yield c
yield content[-1] # last column without a column break
def make_static_columns_r2000(
content: Sequence[str],
width: float,
gutter_width: float,
height: float,
dxfattribs=None,
) -> MText:
if len(content) < 1:
raise ValueError("no content")
columns = MTextColumns.new_static_columns(
len(content), width, gutter_width, height
)
mtext = MText.new(dxfattribs=dxfattribs)
mtext.setup_columns(columns, linked=True)
content = list(add_column_breaks(content))
mtext.text = content[0]
for mt, c in zip(columns.linked_columns, content[1:]):
mt.text = c
return mtext
def make_dynamic_auto_height_columns_r2000(
content: str,
width: float,
gutter_width: float,
height: float,
count: int = 1,
dxfattribs=None,
) -> MText:
if not content:
raise ValueError("no content")
mtext = MText.new(dxfattribs=dxfattribs)
mtext.dxf.width = width
columns = MTextColumns.new_dynamic_auto_height_columns(
count, width, gutter_width, height
)
set_dynamic_columns_content(content, mtext, columns)
return mtext
def make_dynamic_manual_height_columns_r2000(
content: str,
width: float,
gutter_width: float,
heights: Sequence[float],
dxfattribs=None,
) -> MText:
if not content:
raise ValueError("no content")
mtext = MText.new(dxfattribs=dxfattribs)
mtext.dxf.width = width
columns = MTextColumns.new_dynamic_manual_height_columns(
width, gutter_width, heights
)
set_dynamic_columns_content(content, mtext, columns)
return mtext
def set_dynamic_columns_content(
content: str, mtext: MText, columns: MTextColumns
):
mtext.setup_columns(columns, linked=True)
# temp. hack: assign whole content to the main column
mtext.text = content
# for mt, c in zip(mtext.columns.linked_columns, content[1:]):
# mt.text = c
return mtext
# DXF version R2018
def make_static_columns_r2018(
content: Sequence[str],
width: float,
gutter_width: float,
height: float,
dxfattribs=None,
) -> MText:
if len(content) < 1:
raise ValueError("no content")
columns = MTextColumns.new_static_columns(
len(content), width, gutter_width, height
)
mtext = MText.new(dxfattribs=dxfattribs)
mtext.setup_columns(columns, linked=False)
mtext.text = "".join(add_column_breaks(content))
return mtext
def make_dynamic_auto_height_columns_r2018(
content: str,
width: float,
gutter_width: float,
height: float,
count: int,
dxfattribs=None,
) -> MText:
columns = MTextColumns.new_dynamic_auto_height_columns(
count, width, gutter_width, height
)
return _make_dynamic_columns_r2018(content, columns, dxfattribs or {})
def make_dynamic_manual_height_columns_r2018(
content: str,
width: float,
gutter_width: float,
heights: Sequence[float],
dxfattribs=None,
) -> MText:
columns = MTextColumns.new_dynamic_manual_height_columns(
width, gutter_width, heights
)
return _make_dynamic_columns_r2018(content, columns, dxfattribs or {})
def _make_dynamic_columns_r2018(
content: str, columns: MTextColumns, dxfattribs
) -> MText:
if not content:
raise ValueError("no content")
# column count is not required for DXF R2018
mtext = MText.new(dxfattribs=dxfattribs)
mtext.setup_columns(columns, linked=False)
mtext.text = content
return mtext
@@ -0,0 +1,193 @@
# Copyright (c) 2018-2023 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Iterator,
cast,
Optional,
TypeVar,
Generic,
)
from ezdxf.lldxf.const import (
DXFValueError,
DXFKeyError,
INVALID_NAME_CHARACTERS,
)
from ezdxf.lldxf.validator import make_table_key, is_valid_table_name
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import DXFObject, Dictionary
def validate_name(name: str) -> str:
name = name[:255]
if not is_valid_table_name(name):
raise DXFValueError(
f"table name '{name}' contains invalid characters: {INVALID_NAME_CHARACTERS}"
)
return name
T = TypeVar("T", bound="DXFObject")
class ObjectCollection(Generic[T]):
"""
Note:
ObjectCollections may contain entries where the name stored in the entity as
"name" attribute diverges from the key in the DICTIONARY object e.g. MLEADERSTYLE
collection may have entries for "Standard" and "Annotative" but both MLEADERSTYLE
objects have the name "Standard".
"""
def __init__(
self,
doc: Drawing,
dict_name: str = "ACAD_MATERIAL",
object_type: str = "MATERIAL",
):
self.doc: Drawing = doc
self.object_dict_name = dict_name
self.object_type: str = object_type
self.object_dict: Dictionary = doc.rootdict.get_required_dict(dict_name)
def update_object_dict(self) -> None:
self.object_dict = self.doc.rootdict.get_required_dict(self.object_dict_name)
def create_required_entries(self) -> None:
pass
def __iter__(self) -> Iterator[tuple[str, T]]:
return self.object_dict.items()
def __len__(self) -> int:
return len(self.object_dict)
def __contains__(self, name: str) -> bool:
return self.has_entry(name)
def __getitem__(self, name: str) -> T:
entry = self.get(name)
if entry is None:
raise DXFKeyError(name)
return entry
@property
def handle(self) -> str:
"""Returns the DXF handle of the DICTIONARY object."""
return self.object_dict.dxf.handle
@property
def is_hard_owner(self) -> bool:
"""Returns ``True`` if the collection is hard owner of entities.
Hard owned entities will be destroyed by deleting the dictionary.
"""
return self.object_dict.is_hard_owner
def has_entry(self, name: str) -> bool:
return self.get(name) is not None
def is_unique_name(self, name: str) -> bool:
name = make_table_key(name)
for entry_name in self.object_dict.keys():
if make_table_key(entry_name) == name:
return False
return True
def get(self, name: str, default: Optional[T] = None) -> Optional[T]:
"""Get object by name. Object collection entries are case-insensitive.
Args:
name: object name as string
default: default value
"""
name = make_table_key(name)
for entry_name, obj in self.object_dict.items():
if make_table_key(entry_name) == name:
return obj
return default
def new(self, name: str) -> T:
"""Create a new object of type `self.object_type` and store its handle
in the object manager dictionary. Object collection entry names are
case-insensitive and limited to 255 characters.
Args:
name: name of new object as string
Returns:
new object of type `self.object_type`
Raises:
DXFValueError: if object name already exist or is invalid
(internal API)
"""
name = validate_name(name)
if not self.is_unique_name(name):
raise DXFValueError(f"{self.object_type} entry {name} already exists.")
return self._new(name, dxfattribs={"name": name})
def duplicate_entry(self, name: str, new_name: str) -> T:
"""Returns a new table entry `new_name` as copy of `name`,
replaces entry `new_name` if already exist.
Raises:
DXFValueError: `name` does not exist
"""
entry = self.get(name)
if entry is None:
raise DXFValueError(f"entry '{name}' does not exist")
new_name = validate_name(new_name)
# remove existing entry
existing_entry = self.get(new_name)
if existing_entry is not None:
self.delete(new_name)
entitydb = self.doc.entitydb
if entitydb:
new_entry = entitydb.duplicate_entity(entry)
else: # only for testing!
new_entry = entry.copy()
if new_entry.dxf.is_supported("name"):
new_entry.dxf.name = new_name
self.object_dict.add(new_name, new_entry) # type: ignore
owner_handle = self.object_dict.dxf.handle
new_entry.dxf.owner = owner_handle
new_entry.set_reactors([owner_handle])
return new_entry # type: ignore
def _new(self, name: str, dxfattribs: dict) -> T:
objects = self.doc.objects
assert objects is not None
owner = self.object_dict.dxf.handle
dxfattribs["owner"] = owner
obj = objects.add_dxf_object_with_reactor(
self.object_type, dxfattribs=dxfattribs
)
self.object_dict.add(name, obj)
return cast(T, obj)
def delete(self, name: str) -> None:
objects = self.doc.objects
assert objects is not None
obj = self.get(name) # case insensitive
if obj is not None:
# The underlying DICTIONARY is not case-insensitive implemented,
# get real object name if available
if obj.dxf.is_supported("name"):
name = obj.dxf.get("name", name)
self.object_dict.discard(name)
objects.delete_entity(obj)
def clear(self) -> None:
"""Delete all entries."""
self.object_dict.clear()
@@ -0,0 +1,57 @@
# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.lldxf import const
from . import factory
from .dxfgfx import DXFGraphic
from .dxfentity import SubclassProcessor
from .copy import default_copy, CopyNotSupported
from ezdxf.math import BoundingBox, Vec3
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.lldxf.tags import Tags
@factory.register_entity
class OLE2Frame(DXFGraphic):
DXFTYPE = "OLE2FRAME"
MIN_DXF_VERSION_FOR_EXPORT = const.DXF2000
def __init__(self) -> None:
super().__init__()
self.acdb_ole2frame: Optional[Tags] = None
def copy(self, copy_strategy=default_copy) -> OLE2Frame:
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:
self.acdb_ole2frame = processor.subclass_by_index(2)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags. (internal API)"""
# Base class and AcDbEntity export is done by parent class
super().export_entity(tagwriter)
if self.acdb_ole2frame is not None:
tagwriter.write_tags(self.acdb_ole2frame)
# XDATA export is done by the parent class
def bbox(self) -> BoundingBox:
if self.acdb_ole2frame is not None:
v10 = self.acdb_ole2frame.get_first_value(10, None)
v11 = self.acdb_ole2frame.get_first_value(11, None)
if v10 is not None and v11 is not None:
return BoundingBox([Vec3(v10), Vec3(v11)])
return BoundingBox()
def binary_data(self) -> bytes:
if self.acdb_ole2frame is not None:
return b"".join(value for code, value in self.acdb_ole2frame if code == 310)
return b""
@@ -0,0 +1,134 @@
# Copyright (c) 2019-2021 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, TYPE_CHECKING, Optional
from ezdxf.lldxf.tags import Tags, group_tags
from ezdxf.math import Vec2, UVec
from ezdxf.tools import pattern
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["Pattern", "PatternLine"]
class Pattern:
def __init__(self, lines: Optional[Iterable[PatternLine]] = None):
self.lines: list[PatternLine] = list(lines) if lines else []
@classmethod
def load_tags(cls, tags: Tags) -> Pattern:
grouped_line_tags = group_tags(tags, splitcode=53)
return cls(
PatternLine.load_tags(line_tags) for line_tags in grouped_line_tags
)
def clear(self) -> None:
"""Delete all pattern definition lines."""
self.lines = []
def add_line(
self,
angle: float = 0,
base_point: UVec = (0, 0),
offset: UVec = (0, 0),
dash_length_items: Optional[Iterable[float]] = None,
) -> None:
"""Create a new pattern definition line and add the line to the
:attr:`Pattern.lines` attribute.
"""
assert (
dash_length_items is not None
), "argument 'dash_length_items' is None"
self.lines.append(
PatternLine(angle, base_point, offset, dash_length_items)
)
def export_dxf(self, tagwriter: AbstractTagWriter, force=False) -> None:
if len(self.lines) or force:
tagwriter.write_tag2(78, len(self.lines))
for line in self.lines:
line.export_dxf(tagwriter)
def __str__(self) -> str:
return "[" + ",".join(str(line) for line in self.lines) + "]"
def as_list(self) -> list:
return [line.as_list() for line in self.lines]
def scale(self, factor: float = 1, angle: float = 0) -> None:
"""Scale and rotate pattern.
Be careful, this changes the base pattern definition, maybe better use
:meth:`Hatch.set_pattern_scale` or :meth:`Hatch.set_pattern_angle`.
Args:
factor: scaling factor
angle: rotation angle in degrees
"""
scaled_pattern = pattern.scale_pattern(
self.as_list(), factor=factor, angle=angle
)
self.clear()
for line in scaled_pattern:
self.add_line(*line)
class PatternLine:
def __init__(
self,
angle: float = 0,
base_point: UVec = (0, 0),
offset: UVec = (0, 0),
dash_length_items: Optional[Iterable[float]] = None,
):
self.angle: float = float(angle) # in degrees
self.base_point: Vec2 = Vec2(base_point)
self.offset: Vec2 = Vec2(offset)
self.dash_length_items: list[float] = (
[] if dash_length_items is None else list(dash_length_items)
)
# dash_length_items = [item0, item1, ...]
# item > 0 is line, < 0 is gap, 0.0 = dot;
@staticmethod
def load_tags(tags: Tags) -> PatternLine:
p = {53: 0, 43: 0, 44: 0, 45: 0, 46: 0}
dash_length_items = []
for tag in tags:
code, value = tag
if code == 49:
dash_length_items.append(value)
else:
p[code] = value
return PatternLine(
p[53], (p[43], p[44]), (p[45], p[46]), dash_length_items
)
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
write_tag = tagwriter.write_tag2
write_tag(53, self.angle)
write_tag(43, self.base_point.x)
write_tag(44, self.base_point.y)
write_tag(45, self.offset.x)
write_tag(46, self.offset.y)
write_tag(79, len(self.dash_length_items))
for item in self.dash_length_items:
write_tag(49, item)
def __str__(self):
return (
f"[{self.angle}, {self.base_point}, {self.offset}, "
f"{self.dash_length_items}]"
)
def as_list(self) -> list:
return [
self.angle,
self.base_point,
self.offset,
self.dash_length_items,
]
@@ -0,0 +1,145 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator, Optional
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
merge_group_code_mappings,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER
from ezdxf.math import Vec3, Matrix44, NULLVEC, Z_AXIS, OCS
from ezdxf.math.transformtools import (
transform_thickness_and_extrusion_without_ocs,
)
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity, acdb_entity_group_codes
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["Point"]
# Point styling is a global setting, stored in the HEADER section as:
# $PDMODE: https://knowledge.autodesk.com/support/autocad/learn-explore/caas/CloudHelp/cloudhelp/2019/ENU/AutoCAD-Core/files/GUID-82F9BB52-D026-4D6A-ABA6-BF29641F459B-htm.html
# One of these values
# 0 = center dot (.)
# 1 = none ( )
# 2 = cross (+)
# 3 = x-cross (x)
# 4 = tick (')
# Combined with these bit values
# 32 = circle
# 64 = Square
#
# e.g. circle + square+center dot = 32 + 64 + 0 = 96
#
# $PDSIZE: https://knowledge.autodesk.com/support/autocad/learn-explore/caas/CloudHelp/cloudhelp/2021/ENU/AutoCAD-Core/files/GUID-826CA91D-704B-400B-B784-7FCC9619AFB9-htm.html?st=$PDSIZE
# 0 = 5% of draw area height
# <0 = Specifies a percentage of the viewport size
# >0 = Specifies an absolute size
acdb_point = DefSubclass(
"AcDbPoint",
{
# Point location:
"location": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
# Thickness could be negative:
"thickness": DXFAttr(39, default=0, optional=True),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# angle of the x-axis for the UCS in effect when the point was drawn;
# used when PDMODE is nonzero:
"angle": DXFAttr(50, default=0, optional=True),
},
)
acdb_point_group_codes = group_code_mapping(acdb_point)
merged_point_group_codes = merge_group_code_mappings(
acdb_entity_group_codes, acdb_point_group_codes # type: ignore
)
@register_entity
class Point(DXFGraphic):
"""DXF POINT entity"""
DXFTYPE = "POINT"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_point)
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, merged_point_group_codes)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags. (internal API)"""
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_point.name)
self.dxf.export_dxf_attribs(
tagwriter, ["location", "thickness", "extrusion", "angle"]
)
def transform(self, m: Matrix44) -> Point:
"""Transform the POINT entity by transformation matrix `m` inplace."""
self.dxf.location = m.transform(self.dxf.location)
transform_thickness_and_extrusion_without_ocs(self, m)
# ignore dxf.angle!
self.post_transform(m)
return self
def translate(self, dx: float, dy: float, dz: float) -> Point:
"""Optimized POINT translation about `dx` in x-axis, `dy` in y-axis and
`dz` in z-axis.
"""
self.dxf.location = Vec3(dx, dy, dz) + self.dxf.location
# Avoid Matrix44 instantiation if not required:
if self.is_post_transform_required:
self.post_transform(Matrix44.translate(dx, dy, dz))
return self
def virtual_entities(
self, pdsize: float = 1, pdmode: int = 0
) -> Iterator[DXFGraphic]:
"""Yields the graphical representation of POINT as virtual DXF
primitives (LINE and CIRCLE).
The dimensionless point is rendered as zero-length line!
Check for this condition::
e.dxftype() == 'LINE' and e.dxf.start.isclose(e.dxf.end)
if the rendering engine can't handle zero-length lines.
Args:
pdsize: point size in drawing units
pdmode: point styling mode
"""
from ezdxf.render import point
for e in point.virtual_entities(self, pdsize, pdmode):
e.set_source_of_copy(self)
yield e
def ocs(self) -> OCS:
# WCS entity which supports the "extrusion" attribute in a
# different way!
return OCS()
@@ -0,0 +1,463 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Sequence, Optional, Union, TYPE_CHECKING, Iterator
from typing_extensions import Self
import abc
import copy
from ezdxf.audit import Auditor, AuditError
from ezdxf.lldxf import const
from ezdxf.lldxf.tags import Tags
from ezdxf import colors
from ezdxf.tools import pattern
from ezdxf.math import Vec3, Matrix44
from ezdxf.math.transformtools import OCSTransform
from .boundary_paths import BoundaryPaths
from .dxfns import SubclassProcessor, DXFNamespace
from .dxfgfx import DXFGraphic
from .gradient import Gradient
from .pattern import Pattern, PatternLine
from .dxfentity import DXFEntity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf import xref
RGB = colors.RGB
__all__ = ["DXFPolygon"]
PATH_CODES = {
10,
11,
12,
13,
40,
42,
50,
51,
42,
72,
73,
74,
92,
93,
94,
95,
96,
97,
330,
}
PATTERN_DEFINITION_LINE_CODES = {53, 43, 44, 45, 46, 79, 49}
class DXFPolygon(DXFGraphic):
"""Base class for the HATCH and the MPOLYGON entity."""
LOAD_GROUP_CODES: dict[int, Union[str, list[str]]] = {}
def __init__(self) -> None:
super().__init__()
self.paths = BoundaryPaths()
self.pattern: Optional[Pattern] = None
self.gradient: Optional[Gradient] = None
self.seeds: list[tuple[float, float]] = [] # not supported/exported by MPOLYGON
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy paths, pattern, gradient, seeds."""
assert isinstance(entity, DXFPolygon)
entity.paths = copy.deepcopy(self.paths)
entity.pattern = copy.deepcopy(self.pattern)
entity.gradient = copy.deepcopy(self.gradient)
entity.seeds = copy.deepcopy(self.seeds)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
# Copy without subclass marker:
tags = Tags(processor.subclasses[2][1:])
# Removes boundary path data from tags:
tags = self.load_paths(tags)
# Removes gradient data from tags:
tags = self.load_gradient(tags)
# Removes pattern from tags:
tags = self.load_pattern(tags)
# Removes seeds from tags:
tags = self.load_seeds(tags)
# Load HATCH DXF attributes from remaining tags:
processor.fast_load_dxfattribs(
dxf, self.LOAD_GROUP_CODES, subclass=tags, recover=True
)
return dxf
def load_paths(self, tags: Tags) -> Tags:
# Find first group code 91 = count of loops, Spline data also contains
# group code 91!
try:
start_index = tags.tag_index(91)
except const.DXFValueError:
raise const.DXFStructureError(
f"{self.dxftype()}: Missing required DXF tag 'Number of "
f"boundary paths (loops)' (code=91)."
)
path_tags = tags.collect_consecutive_tags(PATH_CODES, start=start_index + 1)
if len(path_tags):
self.paths = BoundaryPaths.load_tags(path_tags)
end_index = start_index + len(path_tags) + 1
del tags[start_index:end_index]
return tags
def load_pattern(self, tags: Tags) -> Tags:
try:
# Group code 78 = Number of pattern definition lines
index = tags.tag_index(78)
except const.DXFValueError:
# No pattern definition lines found.
return tags
pattern_tags = tags.collect_consecutive_tags(
PATTERN_DEFINITION_LINE_CODES, start=index + 1
)
self.pattern = Pattern.load_tags(pattern_tags)
# Delete pattern data including length tag 78
del tags[index : index + len(pattern_tags) + 1]
return tags
def load_gradient(self, tags: Tags) -> Tags:
try:
index = tags.tag_index(450)
except const.DXFValueError:
# No gradient data present
return tags
# Gradient data is always at the end of the AcDbHatch subclass.
self.gradient = Gradient.load_tags(tags[index:]) # type: ignore
# Remove gradient data from tags
del tags[index:]
return tags
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
assert isinstance(clone, DXFPolygon)
assert clone.doc is not None
super().map_resources(clone, mapping)
db = clone.doc.entitydb
for path in clone.paths:
handles = [mapping.get_handle(h) for h in path.source_boundary_objects]
path.source_boundary_objects = [h for h in handles if h in db]
def load_seeds(self, tags: Tags) -> Tags:
return tags
@property
def has_solid_fill(self) -> bool:
"""``True`` if entity has a solid fill. (read only)"""
return bool(self.dxf.solid_fill)
@property
def has_pattern_fill(self) -> bool:
"""``True`` if entity has a pattern fill. (read only)"""
return not bool(self.dxf.solid_fill)
@property
def has_gradient_data(self) -> bool:
"""``True`` if entity has a gradient fill. A hatch with gradient fill
has also a solid fill. (read only)
"""
return bool(self.gradient)
@property
def bgcolor(self) -> Optional[RGB]:
"""
Set pattern fill background color as (r, g, b)-tuple, rgb values
in the range [0, 255] (read/write/del)
usage::
r, g, b = entity.bgcolor # get pattern fill background color
entity.bgcolor = (10, 20, 30) # set pattern fill background color
del entity.bgcolor # delete pattern fill background color
"""
try:
xdata_bgcolor = self.get_xdata("HATCHBACKGROUNDCOLOR")
except const.DXFValueError:
return None
color = xdata_bgcolor.get_first_value(1071, 0)
try:
return colors.int2rgb(int(color))
except ValueError: # invalid data type
return RGB(0, 0, 0)
@bgcolor.setter
def bgcolor(self, rgb: RGB) -> None:
color_value = (
colors.rgb2int(rgb) | -0b111110000000000000000000000000
) # it's magic
self.discard_xdata("HATCHBACKGROUNDCOLOR")
self.set_xdata("HATCHBACKGROUNDCOLOR", [(1071, color_value)])
@bgcolor.deleter
def bgcolor(self) -> None:
self.discard_xdata("HATCHBACKGROUNDCOLOR")
def set_gradient(
self,
color1: RGB = RGB(0, 0, 0),
color2: RGB = RGB(255, 255, 255),
rotation: float = 0.0,
centered: float = 0.0,
one_color: int = 0,
tint: float = 0.0,
name: str = "LINEAR",
) -> None:
"""Sets the gradient fill mode and removes all pattern fill related data, requires
DXF R2004 or newer. A gradient filled hatch is also a solid filled hatch.
Valid gradient type names are:
- "LINEAR"
- "CYLINDER"
- "INVCYLINDER"
- "SPHERICAL"
- "INVSPHERICAL"
- "HEMISPHERICAL"
- "INVHEMISPHERICAL"
- "CURVED"
- "INVCURVED"
Args:
color1: (r, g, b)-tuple for first color, rgb values as int in
the range [0, 255]
color2: (r, g, b)-tuple for second color, rgb values as int in
the range [0, 255]
rotation: rotation angle in degrees
centered: determines whether the gradient is centered or not
one_color: 1 for gradient from `color1` to tinted `color1`
tint: determines the tinted target `color1` for a one color
gradient. (valid range 0.0 to 1.0)
name: name of gradient type, default "LINEAR"
"""
if self.doc is not None and self.doc.dxfversion < const.DXF2004:
raise const.DXFVersionError("Gradient support requires DXF R2004")
if name and name not in const.GRADIENT_TYPES:
raise const.DXFValueError(f"Invalid gradient type name: {name}")
self.pattern = None
self.dxf.solid_fill = 1
self.dxf.pattern_name = "SOLID"
self.dxf.pattern_type = const.HATCH_TYPE_PREDEFINED
gradient = Gradient()
gradient.color1 = color1
gradient.color2 = color2
gradient.one_color = one_color
gradient.rotation = rotation
gradient.centered = centered
gradient.tint = tint
gradient.name = name
self.gradient = gradient
def set_pattern_fill(
self,
name: str,
color: int = 7,
angle: float = 0.0,
scale: float = 1.0,
double: int = 0,
style: int = 1,
pattern_type: int = 1,
definition=None,
) -> None:
"""Sets the pattern fill mode and removes all gradient related data.
The pattern definition should be designed for a scale factor 1 and a rotation
angle of 0 degrees. The predefined hatch pattern like "ANSI33" are scaled
according to the HEADER variable $MEASUREMENT for ISO measurement (m, cm, ... ),
or imperial units (in, ft, ...), this replicates the behavior of BricsCAD.
Args:
name: pattern name as string
color: pattern color as :ref:`ACI`
angle: pattern rotation angle in degrees
scale: pattern scale factor
double: double size flag
style: hatch style (0 = normal; 1 = outer; 2 = ignore)
pattern_type: pattern type (0 = user-defined;
1 = predefined; 2 = custom)
definition: list of definition lines and a definition line is a
4-tuple [angle, base_point, offset, dash_length_items],
see :meth:`set_pattern_definition`
"""
self.gradient = None
self.dxf.solid_fill = 0
self.dxf.pattern_name = name
self.dxf.color = color
self.dxf.pattern_scale = float(scale)
self.dxf.pattern_angle = float(angle)
self.dxf.pattern_double = int(double)
self.dxf.hatch_style = style
self.dxf.pattern_type = pattern_type
if definition is None:
measurement = 1
if self.doc:
measurement = self.doc.header.get("$MEASUREMENT", measurement)
predefined_pattern = (
pattern.ISO_PATTERN if measurement else pattern.IMPERIAL_PATTERN
)
definition = predefined_pattern.get(name, predefined_pattern["ANSI31"])
self.set_pattern_definition(
definition,
factor=self.dxf.pattern_scale,
angle=self.dxf.pattern_angle,
)
def set_pattern_definition(
self, lines: Sequence, factor: float = 1, angle: float = 0
) -> None:
"""Setup pattern definition by a list of definition lines and the
definition line is a 4-tuple (angle, base_point, offset, dash_length_items).
The pattern definition should be designed for a pattern scale factor of 1 and
a pattern rotation angle of 0.
- angle: line angle in degrees
- base-point: (x, y) tuple
- offset: (dx, dy) tuple
- dash_length_items: list of dash items (item > 0 is a line,
item < 0 is a gap and item == 0.0 is a point)
Args:
lines: list of definition lines
factor: pattern scale factor
angle: rotation angle in degrees
"""
if factor != 1 or angle:
lines = pattern.scale_pattern(lines, factor=factor, angle=angle)
self.pattern = Pattern(
[PatternLine(line[0], line[1], line[2], line[3]) for line in lines]
)
def set_pattern_scale(self, scale: float) -> None:
"""Sets the pattern scale factor and scales the pattern definition.
The method always starts from the original base scale, the
:code:`set_pattern_scale(1)` call resets the pattern scale to the original
appearance as defined by the pattern designer, but only if the pattern attribute
:attr:`dxf.pattern_scale` represents the actual scale, it cannot
restore the original pattern scale from the pattern definition itself.
Args:
scale: pattern scale factor
"""
if not self.has_pattern_fill:
return
dxf = self.dxf
self.pattern.scale(factor=1.0 / dxf.pattern_scale * scale) # type: ignore
dxf.pattern_scale = scale
def set_pattern_angle(self, angle: float) -> None:
"""Sets the pattern rotation angle and rotates the pattern definition.
The method always starts from the original base rotation of 0, the
:code:`set_pattern_angle(0)` call resets the pattern rotation angle to the
original appearance as defined by the pattern designer, but only if the
pattern attribute :attr:`dxf.pattern_angle` represents the actual pattern
rotation, it cannot restore the original rotation angle from the
pattern definition itself.
Args:
angle: pattern rotation angle in degrees
"""
if not self.has_pattern_fill:
return
dxf = self.dxf
self.pattern.scale(angle=angle - dxf.pattern_angle) # type: ignore
dxf.pattern_angle = angle % 360.0
def transform(self, m: Matrix44) -> DXFPolygon:
"""Transform entity by transformation matrix `m` inplace."""
dxf = self.dxf
ocs = OCSTransform(dxf.extrusion, m)
elevation = Vec3(dxf.elevation).z
self.paths.transform(ocs, elevation=elevation)
dxf.elevation = ocs.transform_vertex(Vec3(0, 0, elevation)).replace(
x=0.0, y=0.0
)
dxf.extrusion = ocs.new_extrusion
if self.pattern:
# todo: non-uniform scaling
# take the scaling factor of the x-axis
factor = ocs.transform_length((self.dxf.pattern_scale, 0, 0))
angle = ocs.transform_deg_angle(self.dxf.pattern_angle)
# todo: non-uniform pattern scaling is not supported
self.pattern.scale(factor, angle)
self.dxf.pattern_scale = factor
self.dxf.pattern_angle = angle
self.post_transform(m)
return self
def triangulate(self, max_sagitta, min_segments=16) -> Iterator[Sequence[Vec3]]:
"""Triangulate the HATCH/MPOLYGON in OCS coordinates, Elevation and offset is
applied to all vertices.
Args:
max_sagitta: maximum distance from the center of the curve to the
center of the line segment between two approximation points to determine
if a segment should be subdivided.
min_segments: minimum segment count per Bézier curve
.. versionadded:: 1.1
"""
from ezdxf import path
elevation = Vec3(self.dxf.elevation)
if self.dxf.hasattr("offset"): # MPOLYGON
elevation += Vec3(self.dxf.offset) # offset in OCS?
boundary_paths = [path.from_hatch_boundary_path(p) for p in self.paths]
for vertices in path.triangulate(boundary_paths, max_sagitta, min_segments):
yield tuple(elevation + v for v in vertices)
def render_pattern_lines(self) -> Iterator[tuple[Vec3, Vec3]]:
"""Yields the pattern lines in WCS coordinates.
.. versionadded:: 1.1
"""
from ezdxf.render import hatching
if self.has_pattern_fill:
try:
yield from hatching.hatch_entity(self)
except hatching.HatchingError:
return
@abc.abstractmethod
def set_solid_fill(self, color: int = 7, style: int = 1, rgb: Optional[RGB] = None):
...
def audit(self, auditor: Auditor) -> None:
super().audit(auditor)
if not self.is_alive:
return
if not self.paths.is_valid():
auditor.fixed_error(
code=AuditError.INVALID_HATCH_BOUNDARY_PATH,
message=f"Deleted entity {str(self)} containing invalid boundary paths."
)
auditor.trash(self)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
merge_group_code_mappings,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER
from ezdxf.math import NULLVEC, Z_AXIS
from ezdxf.math.transformtools import OCSTransform
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import (
DXFGraphic,
acdb_entity,
elevation_to_z_axis,
acdb_entity_group_codes,
)
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.math import Matrix44
__all__ = ["Shape"]
# Description of the "name" attribute from the DWG documentation: 20.4.37 SHAPE (33)
# In DXF the shape name is stored. When reading from DXF, the shape is found by
# iterating over all the text styles and when the text style contains a shape file,
# iterating over all the shapes until the one with the matching name is found.
acdb_shape = DefSubclass(
"AcDbShape",
{
# Elevation is a legacy feature from R11 and prior, do not use this
# attribute, store the entity elevation in the z-axis of the vertices.
# ezdxf does not export the elevation attribute!
"elevation": DXFAttr(38, default=0, optional=True),
# Thickness could be negative:
"thickness": DXFAttr(39, default=0, optional=True),
# Insertion point (in WCS)
"insert": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
# Shape size:
"size": DXFAttr(40, default=1),
# Shape name:
"name": DXFAttr(2, default=""),
# Rotation angle in degrees:
"rotation": DXFAttr(50, default=0, optional=True),
# Relative X scale factor
"xscale": DXFAttr(
41,
default=1,
optional=True,
validator=validator.is_not_zero,
fixer=RETURN_DEFAULT,
),
# Oblique angle in degrees:
"oblique": DXFAttr(51, default=0, optional=True),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
},
)
acdb_shape_group_codes = group_code_mapping(acdb_shape)
merged_shape_group_codes = merge_group_code_mappings(
acdb_entity_group_codes, acdb_shape_group_codes # type: ignore
)
@register_entity
class Shape(DXFGraphic):
"""DXF SHAPE entity"""
DXFTYPE = "SHAPE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_shape)
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, merged_shape_group_codes)
if processor.r12:
# Transform elevation attribute from R11 to z-axis values:
elevation_to_z_axis(dxf, ("center",))
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_shape.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"insert",
"size",
"name",
"thickness",
"rotation",
"xscale",
"oblique",
"extrusion",
],
)
def transform(self, m: Matrix44) -> Shape:
"""Transform the SHAPE entity by transformation matrix `m` inplace."""
dxf = self.dxf
dxf.insert = m.transform(dxf.insert) # DXF Reference: WCS?
ocs = OCSTransform(self.dxf.extrusion, m)
dxf.rotation = ocs.transform_deg_angle(dxf.rotation)
dxf.size = ocs.transform_length((0, dxf.size, 0))
dxf.x_scale = ocs.transform_length(
(dxf.x_scale, 0, 0), reflection=dxf.x_scale
)
if dxf.hasattr("thickness"):
dxf.thickness = ocs.transform_thickness(dxf.thickness)
dxf.extrusion = ocs.new_extrusion
self.post_transform(m)
return self
@@ -0,0 +1,279 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
merge_group_code_mappings,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER, VERTEXNAMES
from ezdxf.math import Matrix44, Z_AXIS, NULLVEC, Vec3
from ezdxf.math.transformtools import OCSTransform
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import (
DXFGraphic,
acdb_entity,
elevation_to_z_axis,
acdb_entity_group_codes,
)
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.entities import DXFNamespace
__all__ = ["Solid", "Trace", "Face3d"]
acdb_trace = DefSubclass(
"AcDbTrace",
{
# IMPORTANT: all 4 vertices have to be present in the DXF file,
# otherwise AutoCAD shows a DXF structure error and does not load the
# file! (SOLID, TRACE and 3DFACE)
# 1. corner Solid WCS; Trace OCS
"vtx0": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
# 2. corner Solid WCS; Trace OCS
"vtx1": DXFAttr(11, xtype=XType.point3d, default=NULLVEC),
# 3. corner Solid WCS; Trace OCS
"vtx2": DXFAttr(12, xtype=XType.point3d, default=NULLVEC),
# 4. corner Solid WCS; Trace OCS:
# If only three corners are entered to define the SOLID, then the fourth
# corner coordinate is the same as the third.
"vtx3": DXFAttr(13, xtype=XType.point3d, default=NULLVEC),
# IMPORTANT: for TRACE and SOLID the last two vertices are in reversed
# order: a square has the vertex order 0-1-3-2
# Elevation is a legacy feature from R11 and prior, do not use this
# attribute, store the entity elevation in the z-axis of the vertices.
# ezdxf does not export the elevation attribute!
"elevation": DXFAttr(38, default=0, optional=True),
# Thickness could be negative:
"thickness": DXFAttr(39, default=0, optional=True),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
},
)
acdb_trace_group_codes = group_code_mapping(acdb_trace)
merged_trace_group_codes = merge_group_code_mappings(
acdb_entity_group_codes, acdb_trace_group_codes # type: ignore
)
class _Base(DXFGraphic):
def __getitem__(self, num):
return self.dxf.get(VERTEXNAMES[num])
def __setitem__(self, num, value):
return self.dxf.set(VERTEXNAMES[num], value)
@register_entity
class Solid(_Base):
"""DXF SHAPE entity"""
DXFTYPE = "SOLID"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_trace)
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, merged_trace_group_codes)
if processor.r12:
# Transform elevation attribute from R11 to z-axis values:
elevation_to_z_axis(dxf, VERTEXNAMES)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags. (internal API)"""
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_trace.name)
if not self.dxf.hasattr("vtx3"):
self.dxf.vtx3 = self.dxf.vtx2
self.dxf.export_dxf_attribs(
tagwriter,
[
"vtx0",
"vtx1",
"vtx2",
"vtx3",
"thickness",
"extrusion",
],
)
def transform(self, m: Matrix44) -> Solid:
"""Transform the SOLID/TRACE entity by transformation matrix `m` inplace."""
# SOLID and TRACE are OCS entities.
dxf = self.dxf
ocs = OCSTransform(self.dxf.extrusion, m)
for name in VERTEXNAMES:
if dxf.hasattr(name):
dxf.set(name, ocs.transform_vertex(dxf.get(name)))
if dxf.hasattr("thickness"):
dxf.thickness = ocs.transform_thickness(dxf.thickness)
dxf.extrusion = ocs.new_extrusion
self.post_transform(m)
return self
def wcs_vertices(self, close: bool = False) -> list[Vec3]:
"""Returns WCS vertices in correct order,
if argument `close` is ``True``, last vertex == first vertex.
Does **not** return the duplicated last vertex if the entity represents
a triangle.
"""
ocs = self.ocs()
return list(ocs.points_to_wcs(self.vertices(close)))
def vertices(self, close: bool = False) -> list[Vec3]:
"""Returns OCS vertices in correct order,
if argument `close` is ``True``, last vertex == first vertex.
Does **not** return the duplicated last vertex if the entity represents
a triangle.
"""
dxf = self.dxf
vertices: list[Vec3] = [dxf.vtx0, dxf.vtx1, dxf.vtx2]
if dxf.vtx3 != dxf.vtx2: # face is not a triangle
vertices.append(dxf.vtx3)
# adjust weird vertex order of SOLID and TRACE:
# 0, 1, 2, 3 -> 0, 1, 3, 2
if len(vertices) > 3:
vertices[2], vertices[3] = vertices[3], vertices[2]
if close and not vertices[0].isclose(vertices[-1]):
vertices.append(vertices[0])
return vertices
@register_entity
class Trace(Solid):
"""DXF TRACE entity"""
DXFTYPE = "TRACE"
acdb_face = DefSubclass(
"AcDbFace",
{
# 1. corner WCS:
"vtx0": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
# 2. corner WCS:
"vtx1": DXFAttr(11, xtype=XType.point3d, default=NULLVEC),
# 3. corner WCS:
"vtx2": DXFAttr(12, xtype=XType.point3d, default=NULLVEC),
# 4. corner WCS:
# If only three corners are entered to define the SOLID, then the fourth
# corner coordinate is the same as the third.
"vtx3": DXFAttr(13, xtype=XType.point3d, default=NULLVEC),
# invisible:
# 1 = First edge is invisible
# 2 = Second edge is invisible
# 4 = Third edge is invisible
# 8 = Fourth edge is invisible
"invisible_edges": DXFAttr(70, default=0, optional=True),
},
)
acdb_face_group_codes = group_code_mapping(acdb_face)
merged_face_group_codes = merge_group_code_mappings(
acdb_entity_group_codes, acdb_face_group_codes # type: ignore
)
@register_entity
class Face3d(_Base):
"""DXF 3DFACE entity"""
# IMPORTANT: for 3DFACE the last two vertices are in regular order:
# a square has the vertex order 0-1-2-3
DXFTYPE = "3DFACE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_face)
def is_invisible_edge(self, num: int) -> bool:
"""Returns True if edge `num` is an invisible edge."""
if num < 0 or num > 4:
raise ValueError(f"invalid edge: {num}")
return bool(self.dxf.invisible_edges & (1 << num))
def set_edge_visibility(self, num: int, visible: bool = False) -> None:
"""Set visibility of edge `num`, status `True` for visible, status
`False` for invisible.
"""
if num < 0 or num >= 4:
raise ValueError(f"invalid edge: {num}")
if not visible:
self.dxf.invisible_edges = self.dxf.invisible_edges | (1 << num)
else:
self.dxf.invisible_edges = self.dxf.invisible_edges & ~(1 << num)
def get_edges_visibility(self) -> list[bool]:
# if the face is a triangle, a fourth visibility flag
# may be present but is ignored
return [not self.is_invisible_edge(i) for i in range(4)]
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, merged_face_group_codes)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_face.name)
if not self.dxf.hasattr("vtx3"):
self.dxf.vtx3 = self.dxf.vtx2
self.dxf.export_dxf_attribs(
tagwriter, ["vtx0", "vtx1", "vtx2", "vtx3", "invisible_edges"]
)
def transform(self, m: Matrix44) -> Face3d:
"""Transform the 3DFACE entity by transformation matrix `m` inplace."""
dxf = self.dxf
# 3DFACE is a real 3d entity
dxf.vtx0, dxf.vtx1, dxf.vtx2, dxf.vtx3 = m.transform_vertices(
(dxf.vtx0, dxf.vtx1, dxf.vtx2, dxf.vtx3)
)
self.post_transform(m)
return self
def wcs_vertices(self, close: bool = False) -> list[Vec3]:
"""Returns WCS vertices, if argument `close` is ``True``,
the first vertex is also returned as closing last vertex.
Returns 4 vertices when `close` is ``False`` and 5 vertices when `close` is
``True``. Some edges may have zero-length. This is a compatibility interface
to SOLID and TRACE. The 3DFACE entity is already defined by WCS vertices.
"""
dxf = self.dxf
vertices: list[Vec3] = [dxf.vtx0, dxf.vtx1, dxf.vtx2]
vtx3 = dxf.get("vtx3")
if (
isinstance(vtx3, Vec3) and vtx3 != dxf.vtx2
): # face is not a triangle
vertices.append(vtx3)
if close:
vertices.append(vertices[0])
return vertices
@@ -0,0 +1,201 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Iterable
from typing_extensions import Self
import logging
from ezdxf.lldxf import const, validator
from ezdxf.lldxf.attributes import (
DXFAttributes,
DefSubclass,
DXFAttr,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.math import UVec, Vec2, Matrix44, Z_AXIS, NULLVEC
from ezdxf.entities import factory
from .dxfentity import SubclassProcessor, base_class
from .dxfobj import DXFObject
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.lldxf.tags import Tags
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["SpatialFilter"]
# The HEADER variable $XCLIPFRAME determines if the clipping path polygon is displayed
# and plotted:
# 0 - not displayed, not plotted
# 1 - displayed, not plotted
# 2 - displayed and plotted
logger = logging.getLogger("ezdxf")
AcDbFilter = "AcDbFilter"
AcDbSpatialFilter = "AcDbSpatialFilter"
acdb_filter = DefSubclass(AcDbFilter, {})
acdb_spatial_filter = DefSubclass(
AcDbSpatialFilter,
{
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
"origin": DXFAttr(11, xtype=XType.point3d, default=NULLVEC),
"is_clipping_enabled": DXFAttr(
71, default=1, validator=validator.is_integer_bool, fixer=RETURN_DEFAULT
),
"has_front_clipping_plane": DXFAttr(
72, default=0, validator=validator.is_integer_bool, fixer=RETURN_DEFAULT
),
"front_clipping_plane_distance": DXFAttr(40, default=0.0),
"has_back_clipping_plane": DXFAttr(
73, default=0, validator=validator.is_integer_bool, fixer=RETURN_DEFAULT
),
"back_clipping_plane_distance": DXFAttr(41, default=0.0),
},
)
acdb_spatial_filter_group_codes = group_code_mapping(acdb_spatial_filter)
@factory.register_entity
class SpatialFilter(DXFObject):
DXFTYPE = "SPATIAL_FILTER"
DXFATTRIBS = DXFAttributes(base_class, acdb_filter, acdb_spatial_filter)
MIN_DXF_VERSION_FOR_EXPORT = const.DXF2000
def __init__(self) -> None:
super().__init__()
# clipping path vertices in OCS coordinates
self._boundary_vertices: tuple[Vec2, ...] = tuple()
# This matrix is the inverse of the original block reference (insert entity)
# transformation. The original block reference transformation is the one that
# is applied to all entities in the block when the block reference is regenerated.
self._inverse_insert_matrix = Matrix44()
# This matrix transforms points into the coordinate system of the clip boundary.
self._transform_matrix = Matrix44()
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, SpatialFilter)
# immutable data
entity._boundary_vertices = self._boundary_vertices
entity._inverse_insert_matrix = self._inverse_insert_matrix
entity._transform_matrix = self._transform_matrix
@property
def boundary_vertices(self) -> tuple[Vec2, ...]:
"""Returns the clipping path vertices in OCS coordinates."""
return self._boundary_vertices
def set_boundary_vertices(self, vertices: Iterable[UVec]) -> None:
"""Set the clipping path vertices in OCS coordinates."""
self._boundary_vertices = tuple(Vec2(v) for v in vertices)
if len(self._boundary_vertices) < 2:
raise const.DXFValueError("2 or more vertices required")
@property
def inverse_insert_matrix(self) -> Matrix44:
"""Returns the inverse insert matrix.
This matrix is the inverse of the original block reference (insert entity)
transformation. The original block reference transformation is the one that
is applied to all entities in the block when the block reference is regenerated.
"""
return self._inverse_insert_matrix.copy()
def set_inverse_insert_matrix(self, m: Matrix44) -> None:
self._inverse_insert_matrix = m.copy()
@property
def transform_matrix(self) -> Matrix44:
"""Returns the transform matrix.
This matrix transforms points into the coordinate system of the clip boundary.
"""
return self._transform_matrix.copy()
def set_transform_matrix(self, m: Matrix44) -> None:
self._transform_matrix = m.copy()
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.find_subclass(AcDbSpatialFilter)
if tags:
try:
self._load_boundary_data(tags)
except IndexError:
logger.warning(
f"Not enough matrix values in SPATIAL_FILTER(#{processor.handle})"
)
processor.fast_load_dxfattribs(
dxf, acdb_spatial_filter_group_codes, subclass=tags
)
else:
logger.warning(
f"Required subclass 'AcDbSpatialFilter' in object "
f"SPATIAL_FILTER(#{processor.handle}) not present"
)
return dxf
def _load_boundary_data(self, tags: Tags) -> None:
def to_matrix(v: list[float]) -> Matrix44:
# raises IndexError if not enough matrix values exist
return Matrix44(
# fmt: off
[
v[0], v[4], v[8], 0.0,
v[1], v[5], v[9], 0.0,
v[2], v[6], v[10], 0.0,
v[3], v[7], v[11], 1.0,
]
# fmt: on
)
self._boundary_vertices = tuple(Vec2(tag.value) for tag in tags.find_all(10))
matrix_values = [float(tag.value) for tag in tags.find_all(40)]
# use only last 24 values
self._inverse_insert_matrix = to_matrix(matrix_values[-24:-12])
self._transform_matrix = to_matrix(matrix_values[-12:])
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
def write_matrix(m: Matrix44) -> None:
for index in range(3):
for value in m.get_col(index):
tagwriter.write_tag2(40, value)
super().export_entity(tagwriter)
tagwriter.write_tag2(const.SUBCLASS_MARKER, AcDbFilter)
tagwriter.write_tag2(const.SUBCLASS_MARKER, AcDbSpatialFilter)
tagwriter.write_tag2(70, len(self._boundary_vertices))
for vertex in self._boundary_vertices:
tagwriter.write_vertex(10, vertex)
self.dxf.export_dxf_attribs(
tagwriter, ["extrusion", "origin", "is_clipping_enabled"]
)
has_front_clipping = self.dxf.has_front_clipping_plane
tagwriter.write_tag2(72, has_front_clipping)
if has_front_clipping:
# AutoCAD does no accept tag 40, if front clipping is disabled
tagwriter.write_tag2(40, self.dxf.front_clipping_plane_distance)
has_back_clipping = self.dxf.has_back_clipping_plane
tagwriter.write_tag2(73, has_back_clipping)
if has_back_clipping:
# AutoCAD does no accept tag 41, if back clipping is disabled
tagwriter.write_tag2(41, self.dxf.back_clipping_plane_distance)
write_matrix(self._inverse_insert_matrix)
write_matrix(self._transform_matrix)
@@ -0,0 +1,629 @@
# Copyright (c) 2019-2025 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
List,
Iterable,
Sequence,
cast,
Iterator,
Optional,
)
from typing_extensions import TypeAlias, Self
import array
import copy
from ezdxf.audit import AuditError
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import (
SUBCLASS_MARKER,
DXF2000,
DXFValueError,
DXFStructureError,
)
from ezdxf.lldxf.packedtags import VertexArray, Tags
from ezdxf.math import (
Vec3,
UVec,
Matrix44,
ConstructionEllipse,
Z_AXIS,
NULLVEC,
OCS,
uniform_knot_vector,
open_uniform_knot_vector,
BSpline,
required_knot_values,
required_fit_points,
required_control_points,
fit_points_to_cad_cv,
round_knots,
)
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity
from .factory import register_entity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace, Ellipse
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.audit import Auditor
__all__ = ["Spline"]
# From the Autodesk ObjectARX reference:
# Objects of the AcDbSpline class use an embedded gelib object to maintain the
# actual spline information.
#
# Book recommendations:
#
# - "Curves and Surfaces for CAGD" by Gerald Farin
# - "Mathematical Elements for Computer Graphics"
# by David Rogers and Alan Adams
# - "An Introduction To Splines For Use In Computer Graphics & Geometric Modeling"
# by Richard H. Bartels, John C. Beatty, and Brian A Barsky
#
# http://help.autodesk.com/view/OARX/2018/ENU/?guid=OREF-AcDbSpline__setFitData_AcGePoint3dArray__AcGeVector3d__AcGeVector3d__AcGe__KnotParameterization_int_double
# Construction of a AcDbSpline entity from fit points:
# degree has no effect. A spline with degree=3 is always constructed when
# interpolating a series of fit points.
acdb_spline = DefSubclass(
"AcDbSpline",
{
# Spline flags:
# 1 = Closed spline
# 2 = Periodic spline
# 4 = Rational spline
# 8 = Planar
# 16 = Linear (planar bit is also set)
"flags": DXFAttr(70, default=0),
# degree: The degree can't be higher than 11 according to the Autodesk
# ObjectARX reference.
"degree": DXFAttr(71, default=3, validator=validator.is_positive),
"n_knots": DXFAttr(72, xtype=XType.callback, getter="knot_count"),
"n_control_points": DXFAttr(
73, xtype=XType.callback, getter="control_point_count"
),
"n_fit_points": DXFAttr(74, xtype=XType.callback, getter="fit_point_count"),
"knot_tolerance": DXFAttr(42, default=1e-10, optional=True),
"control_point_tolerance": DXFAttr(43, default=1e-10, optional=True),
"fit_tolerance": DXFAttr(44, default=1e-10, optional=True),
# Start- and end tangents should be normalized, but CAD applications do not
# crash if they are not normalized.
"start_tangent": DXFAttr(
12,
xtype=XType.point3d,
optional=True,
validator=validator.is_not_null_vector,
),
"end_tangent": DXFAttr(
13,
xtype=XType.point3d,
optional=True,
validator=validator.is_not_null_vector,
),
# Extrusion is the normal vector (omitted if the spline is non-planar)
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# 10: Control points (in WCS); one entry per control point
# 11: Fit points (in WCS); one entry per fit point
# 40: Knot value (one entry per knot)
# 41: Weight (if not 1); with multiple group pairs, they are present if all
# are not 1
},
)
acdb_spline_group_codes = group_code_mapping(acdb_spline)
class SplineData:
def __init__(self, spline: Spline):
self.fit_points = spline.fit_points
self.control_points = spline.control_points
self.knots = spline.knots
self.weights = spline.weights
REMOVE_CODES = {10, 11, 40, 41, 72, 73, 74}
Vertices: TypeAlias = List[Sequence[float]]
@register_entity
class Spline(DXFGraphic):
"""DXF SPLINE entity"""
DXFTYPE = "SPLINE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_spline)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
CLOSED = 1 # closed b-spline
PERIODIC = 2 # uniform b-spline
RATIONAL = 4 # rational b-spline
PLANAR = 8 # all spline points in a plane, don't read or set this bit, just ignore like AutoCAD
LINEAR = 16 # always set with PLANAR, don't read or set this bit, just ignore like AutoCAD
def __init__(self):
super().__init__()
self.fit_points = VertexArray()
self.control_points = VertexArray()
self.knots = []
self.weights = []
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy data: control_points, fit_points, weights, knot_values."""
assert isinstance(entity, Spline)
entity._control_points = copy.deepcopy(self._control_points)
entity._fit_points = copy.deepcopy(self._fit_points)
entity._knots = copy.deepcopy(self._knots)
entity._weights = copy.deepcopy(self._weights)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.subclass_by_index(2)
if tags:
tags = Tags(self.load_spline_data(tags))
processor.fast_load_dxfattribs(
dxf, acdb_spline_group_codes, subclass=tags, recover=True
)
else:
raise DXFStructureError(
f"missing 'AcDbSpline' subclass in SPLINE(#{dxf.handle})"
)
return dxf
def load_spline_data(self, tags) -> Iterator:
"""Load and set spline data (fit points, control points, weights,
knots) and remove invalid start- and end tangents.
Yields the remaining unprocessed tags.
"""
control_points = []
fit_points = []
knots = []
weights = []
for tag in tags:
code, value = tag
if code == 10:
control_points.append(value)
elif code == 11:
fit_points.append(value)
elif code == 40:
knots.append(value)
elif code == 41:
weights.append(value)
elif code in (12, 13) and NULLVEC.isclose(value):
# Tangent values equal to (0, 0, 0) are invalid and ignored at
# the loading stage!
pass
else:
yield tag
self.control_points = control_points
self.fit_points = fit_points
self.knots = knots
self.weights = weights
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_spline.name)
self.dxf.export_dxf_attribs(tagwriter, ["extrusion", "flags", "degree"])
tagwriter.write_tag2(72, self.knot_count())
tagwriter.write_tag2(73, self.control_point_count())
tagwriter.write_tag2(74, self.fit_point_count())
self.dxf.export_dxf_attribs(
tagwriter,
[
"knot_tolerance",
"control_point_tolerance",
"fit_tolerance",
"start_tangent",
"end_tangent",
],
)
self.export_spline_data(tagwriter)
def export_spline_data(self, tagwriter: AbstractTagWriter):
for value in self._knots:
tagwriter.write_tag2(40, value)
if len(self._weights):
for value in self._weights:
tagwriter.write_tag2(41, value)
self._control_points.export_dxf(tagwriter, code=10) # type: ignore
self._fit_points.export_dxf(tagwriter, code=11) # type: ignore
@property
def closed(self) -> bool:
"""``True`` if spline is closed. A closed spline has a connection from
the last control point to the first control point. (read/write)
"""
return self.get_flag_state(self.CLOSED, name="flags")
@closed.setter
def closed(self, status: bool) -> None:
self.set_flag_state(self.CLOSED, state=status, name="flags")
@property
def knots(self) -> list[float]:
"""Knot values as :code:`array.array('d')`."""
return self._knots
@knots.setter
def knots(self, values: Iterable[float]) -> None:
self._knots: list[float] = cast(List[float], array.array("d", values))
# DXF callback attribute Spline.dxf.n_knots
def knot_count(self) -> int:
"""Count of knot values."""
return len(self._knots)
@property
def weights(self) -> list[float]:
"""Control point weights as :code:`array.array('d')`."""
return self._weights
@weights.setter
def weights(self, values: Iterable[float]) -> None:
self._weights: list[float] = cast(List[float], array.array("d", values))
@property
def control_points(self) -> Vertices:
""":class:`~ezdxf.lldxf.packedtags.VertexArray` of control points in
:ref:`WCS`.
"""
return self._control_points
@control_points.setter
def control_points(self, points: Iterable[UVec]) -> None:
self._control_points: Vertices = cast(Vertices, VertexArray(Vec3.list(points)))
# DXF callback attribute Spline.dxf.n_control_points
def control_point_count(self) -> int:
"""Count of control points."""
return len(self.control_points)
@property
def fit_points(self) -> Vertices:
""":class:`~ezdxf.lldxf.packedtags.VertexArray` of fit points in
:ref:`WCS`.
"""
return self._fit_points
@fit_points.setter
def fit_points(self, points: Iterable[UVec]) -> None:
self._fit_points: Vertices = cast(
Vertices,
VertexArray(Vec3.list(points)),
)
# DXF callback attribute Spline.dxf.n_fit_points
def fit_point_count(self) -> int:
"""Count of fit points."""
return len(self.fit_points)
def construction_tool(self) -> BSpline:
"""Returns the construction tool :class:`ezdxf.math.BSpline`."""
if self.control_point_count():
weights = self.weights if len(self.weights) else None
if len(self.knots):
knots = round_knots(self.knots, self.dxf.knot_tolerance)
else:
knots = None
return BSpline(
control_points=self.control_points,
order=self.dxf.degree + 1,
knots=knots,
weights=weights,
)
elif self.fit_point_count():
tangents = None
if self.dxf.hasattr("start_tangent") and self.dxf.hasattr("end_tangent"):
tangents = [self.dxf.start_tangent, self.dxf.end_tangent]
# SPLINE from fit points has always a degree of 3!
return fit_points_to_cad_cv(
self.fit_points,
tangents=tangents,
)
else:
raise ValueError("Construction tool requires control- or fit points.")
def apply_construction_tool(self, s) -> Spline:
"""Apply SPLINE data from a :class:`~ezdxf.math.BSpline` construction
tool or from a :class:`geomdl.BSpline.Curve` object.
"""
try:
self.control_points = s.control_points
except AttributeError: # maybe a geomdl.BSpline.Curve class
s = BSpline.from_nurbs_python_curve(s)
self.control_points = s.control_points
self.dxf.degree = s.degree
self.fit_points = [] # remove fit points
self.knots = s.knots()
self.weights = s.weights()
self.set_flag_state(Spline.RATIONAL, state=bool(len(self.weights)))
return self # floating interface
def flattening(self, distance: float, segments: int = 4) -> Iterator[Vec3]:
"""Adaptive recursive flattening. The argument `segments` is the
minimum count of approximation segments between two knots, if the
distance from the center of the approximation segment to the curve is
bigger than `distance` the segment will be subdivided.
Args:
distance: maximum distance from the projected curve point onto the
segment chord.
segments: minimum segment count between two knots
"""
return self.construction_tool().flattening(distance, segments)
@classmethod
def from_arc(cls, entity: DXFGraphic) -> Spline:
"""Create a new SPLINE entity from a CIRCLE, ARC or ELLIPSE entity.
The new SPLINE entity has no owner, no handle, is not stored in
the entity database nor assigned to any layout!
"""
dxftype = entity.dxftype()
if dxftype == "ELLIPSE":
ellipse = cast("Ellipse", entity).construction_tool()
elif dxftype == "CIRCLE":
ellipse = ConstructionEllipse.from_arc(
center=entity.dxf.get("center", NULLVEC),
radius=abs(entity.dxf.get("radius", 1.0)),
extrusion=entity.dxf.get("extrusion", Z_AXIS),
)
elif dxftype == "ARC":
ellipse = ConstructionEllipse.from_arc(
center=entity.dxf.get("center", NULLVEC),
radius=abs(entity.dxf.get("radius", 1.0)),
extrusion=entity.dxf.get("extrusion", Z_AXIS),
start_angle=entity.dxf.get("start_angle", 0),
end_angle=entity.dxf.get("end_angle", 360),
)
else:
raise TypeError("CIRCLE, ARC or ELLIPSE entity required.")
spline = Spline.new(dxfattribs=entity.graphic_properties(), doc=entity.doc)
s = BSpline.from_ellipse(ellipse)
spline.dxf.degree = s.degree
spline.dxf.flags = Spline.RATIONAL
spline.control_points = s.control_points # type: ignore
spline.knots = s.knots() # type: ignore
spline.weights = s.weights() # type: ignore
return spline
def set_open_uniform(self, control_points: Sequence[UVec], degree: int = 3) -> None:
"""Open B-spline with a uniform knot vector, start and end at your first
and last control points.
"""
self.dxf.flags = 0
self.dxf.degree = degree
self.control_points = control_points # type: ignore
self.knots = open_uniform_knot_vector(len(control_points), degree + 1)
def set_uniform(self, control_points: Sequence[UVec], degree: int = 3) -> None:
"""B-spline with a uniform knot vector, does NOT start and end at your
first and last control points.
"""
self.dxf.flags = 0
self.dxf.degree = degree
self.control_points = control_points # type: ignore
self.knots = uniform_knot_vector(len(control_points), degree + 1)
def set_closed(self, control_points: Sequence[UVec], degree=3) -> None:
"""Closed B-spline with a uniform knot vector, start and end at your
first control point.
"""
self.dxf.flags = self.PERIODIC | self.CLOSED
self.dxf.degree = degree
self.control_points = control_points # type: ignore
self.control_points.extend(control_points[:degree])
# AutoDesk Developer Docs:
# If the spline is periodic, the length of knot vector will be greater
# than length of the control array by 1, but this does not work with
# BricsCAD.
self.knots = uniform_knot_vector(len(self.control_points), degree + 1)
def set_open_rational(
self,
control_points: Sequence[UVec],
weights: Sequence[float],
degree: int = 3,
) -> None:
"""Open rational B-spline with a uniform knot vector, start and end at
your first and last control points, and has additional control
possibilities by weighting each control point.
"""
self.set_open_uniform(control_points, degree=degree)
self.dxf.flags = self.dxf.flags | self.RATIONAL
if len(weights) != len(self.control_points):
raise DXFValueError("Control point count must be equal to weights count.")
self.weights = weights # type: ignore
def set_uniform_rational(
self,
control_points: Sequence[UVec],
weights: Sequence[float],
degree: int = 3,
) -> None:
"""Rational B-spline with a uniform knot vector, does NOT start and end
at your first and last control points, and has additional control
possibilities by weighting each control point.
"""
self.set_uniform(control_points, degree=degree)
self.dxf.flags = self.dxf.flags | self.RATIONAL
if len(weights) != len(self.control_points):
raise DXFValueError("Control point count must be equal to weights count.")
self.weights = weights # type: ignore
def set_closed_rational(
self,
control_points: Sequence[UVec],
weights: Sequence[float],
degree: int = 3,
) -> None:
"""Closed rational B-spline with a uniform knot vector, start and end at
your first control point, and has additional control possibilities by
weighting each control point.
"""
self.set_closed(control_points, degree=degree)
self.dxf.flags = self.dxf.flags | self.RATIONAL
weights = list(weights)
weights.extend(weights[:degree])
if len(weights) != len(self.control_points):
raise DXFValueError("Control point count must be equal to weights count.")
self.weights = weights
def transform(self, m: Matrix44) -> Spline:
"""Transform the SPLINE entity by transformation matrix `m` inplace."""
self._control_points.transform(m) # type: ignore
self._fit_points.transform(m) # type: ignore
# Transform optional attributes if they exist
dxf = self.dxf
for name in ("start_tangent", "end_tangent", "extrusion"):
if dxf.hasattr(name):
dxf.set(name, m.transform_direction(dxf.get(name)))
self.post_transform(m)
return self
def audit(self, auditor: Auditor) -> None:
"""Audit the SPLINE entity."""
super().audit(auditor)
degree = self.dxf.degree
name = str(self)
if degree < 1:
auditor.fixed_error(
code=AuditError.INVALID_SPLINE_DEFINITION,
message=f"Removed {name} with invalid degree: {degree} < 1.",
)
auditor.trash(self)
return
n_control_points = len(self.control_points)
n_fit_points = len(self.fit_points)
if n_control_points == 0 and n_fit_points == 0:
auditor.fixed_error(
code=AuditError.INVALID_SPLINE_DEFINITION,
message=f"Removed {name} without any points (no geometry).",
)
auditor.trash(self)
return
if n_control_points > 0:
self._audit_control_points(auditor)
# Ignore fit points if defined by control points
elif n_fit_points > 0:
self._audit_fit_points(auditor)
def _audit_control_points(self, auditor: Auditor):
name = str(self)
order = self.dxf.degree + 1
n_control_points = len(self.control_points)
# Splines with to few control points can't be processed:
n_control_points_required = required_control_points(order)
if n_control_points < n_control_points_required:
auditor.fixed_error(
code=AuditError.INVALID_SPLINE_CONTROL_POINT_COUNT,
message=f"Removed {name} with invalid control point count: "
f"{n_control_points} < {n_control_points_required}",
)
auditor.trash(self)
return
n_weights = len(self.weights)
n_knots = len(self.knots)
n_knots_required = required_knot_values(n_control_points, order)
if n_knots < n_knots_required:
# Can not fix entity: because the knot values are basic
# values which define the geometry of SPLINE.
auditor.fixed_error(
code=AuditError.INVALID_SPLINE_KNOT_VALUE_COUNT,
message=f"Removed {name} with invalid knot value count: "
f"{n_knots} < {n_knots_required}",
)
auditor.trash(self)
return
if n_weights and n_weights != n_control_points:
# Can not fix entity: because the weights are basic
# values which define the geometry of SPLINE.
auditor.fixed_error(
code=AuditError.INVALID_SPLINE_WEIGHT_COUNT,
message=f"Removed {name} with invalid weight count: "
f"{n_weights} != {n_control_points}",
)
auditor.trash(self)
return
def _audit_fit_points(self, auditor: Auditor):
name = str(self)
order = self.dxf.degree + 1
# Assuming end tangents will be estimated if not present,
# like by ezdxf:
n_fit_points_required = required_fit_points(order, tangents=True)
# Splines with to few fit points can't be processed:
n_fit_points = len(self.fit_points)
if n_fit_points < n_fit_points_required:
auditor.fixed_error(
code=AuditError.INVALID_SPLINE_FIT_POINT_COUNT,
message=f"Removed {name} with invalid fit point count: "
f"{n_fit_points} < {n_fit_points_required}",
)
auditor.trash(self)
return
# Knot values have no meaning for splines defined by fit points:
if len(self.knots):
auditor.fixed_error(
code=AuditError.INVALID_SPLINE_KNOT_VALUE_COUNT,
message=f"Removed unused knot values for {name} "
f"defined by fit points.",
)
self.knots = []
# Weights have no meaning for splines defined by fit points:
if len(self.weights):
auditor.fixed_error(
code=AuditError.INVALID_SPLINE_WEIGHT_COUNT,
message=f"Removed unused weights for {name} " f"defined by fit points.",
)
self.weights = []
def ocs(self) -> OCS:
# WCS entity which supports the "extrusion" attribute in a
# different way!
return OCS()
@@ -0,0 +1,217 @@
# Copyright (c) 2020-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Callable, Optional
from typing_extensions import Self
from ezdxf.entities import factory, DXFGraphic, SeqEnd, DXFEntity
from ezdxf.lldxf import const
from .copy import default_copy
import logging
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import DXFEntity
from ezdxf.entitydb import EntityDB
from ezdxf import xref
__all__ = ["entity_linker", "LinkedEntities"]
logger = logging.getLogger("ezdxf")
class LinkedEntities(DXFGraphic):
"""Super class for common features of the INSERT and the POLYLINE entity.
Both have linked entities like the VERTEX or ATTRIB entity and a
SEQEND entity.
"""
def __init__(self) -> None:
super().__init__()
self._sub_entities: list[DXFGraphic] = []
self.seqend: Optional[SeqEnd] = None
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy all sub-entities ands SEQEND. (internal API)"""
assert isinstance(entity, LinkedEntities)
entity._sub_entities = [
copy_strategy.copy(e) for e in self._sub_entities
]
if self.seqend:
entity.seqend = copy_strategy.copy(self.seqend)
def link_entity(self, entity: DXFEntity) -> None:
"""Link VERTEX to ATTRIB entities."""
assert isinstance(entity, DXFGraphic)
entity.set_owner(self.dxf.handle, self.dxf.paperspace)
self._sub_entities.append(entity)
def link_seqend(self, seqend: DXFEntity) -> None:
"""Link SEQEND entity. (internal API)"""
seqend.dxf.owner = self.dxf.owner
if self.seqend is not None: # destroy existing seqend
self.seqend.destroy()
self.seqend = seqend # type: ignore
def post_bind_hook(self):
"""Create always a SEQEND entity."""
if self.seqend is None:
self.new_seqend()
def all_sub_entities(self) -> Iterable[DXFEntity]:
"""Yields all sub-entities and SEQEND. (internal API)"""
yield from self._sub_entities
if self.seqend:
yield self.seqend
def process_sub_entities(self, func: Callable[[DXFEntity], None]):
"""Call `func` for all sub-entities and SEQEND. (internal API)"""
for entity in self.all_sub_entities():
if entity.is_alive:
func(entity)
def add_sub_entities_to_entitydb(self, db: EntityDB) -> None:
"""Add sub-entities (VERTEX, ATTRIB, SEQEND) to entity database `db`,
called from EntityDB. (internal API)
"""
def add(entity: DXFEntity):
entity.doc = self.doc # grant same document
db.add(entity)
if not self.seqend or not self.seqend.is_alive:
self.new_seqend()
self.process_sub_entities(add)
def new_seqend(self):
"""Create and bind new SEQEND. (internal API)"""
attribs = {"layer": self.dxf.layer}
if self.doc:
seqend = factory.create_db_entry("SEQEND", attribs, self.doc)
else:
seqend = factory.new("SEQEND", attribs)
self.link_seqend(seqend)
def set_owner(self, owner: Optional[str], paperspace: int = 0):
"""Set owner of all sub-entities and SEQEND. (internal API)"""
# Loading from file: POLYLINE/INSERT will be added to layout before
# vertices/attrib entities are linked, so set_owner() of POLYLINE does
# not set owner of vertices at loading time.
super().set_owner(owner, paperspace)
self.take_ownership()
def take_ownership(self):
"""Take ownership of all sub-entities and SEQEND. (internal API)"""
handle = self.dxf.handle
paperspace = self.dxf.paperspace
for entity in self.all_sub_entities():
if entity.is_alive:
entity.dxf.owner = handle
entity.dxf.paperspace = paperspace
def remove_dependencies(self, other: Optional[Drawing] = None):
"""Remove all dependencies from current document to bind entity to
`other` document. (internal API)
"""
self.process_sub_entities(lambda e: e.remove_dependencies(other))
super().remove_dependencies(other)
def destroy(self) -> None:
"""Destroy all data and references."""
if not self.is_alive:
return
self.process_sub_entities(func=lambda e: e.destroy())
del self._sub_entities
del self.seqend
super().destroy()
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
super().register_resources(registry)
self.process_sub_entities(lambda e: e.register_resources(registry))
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
assert isinstance(clone, LinkedEntities)
super().map_resources(clone, mapping)
for source, _clone in zip(self.all_sub_entities(), clone.all_sub_entities()):
source.map_resources(_clone, mapping)
LINKED_ENTITIES = {"INSERT": "ATTRIB", "POLYLINE": "VERTEX"}
def entity_linker() -> Callable[[DXFEntity], bool]:
"""Create an DXF entities linker."""
main_entity: Optional[DXFEntity] = None
expected_dxftype = ""
def entity_linker_(entity: DXFEntity) -> bool:
"""Collect and link entities which are linked to a parent entity:
- VERTEX -> POLYLINE
- ATTRIB -> INSERT
Args:
entity: examined DXF entity
Returns:
True if `entity` is linked to a parent entity
"""
nonlocal main_entity, expected_dxftype
dxftype: str = entity.dxftype()
# INSERT & POLYLINE are not linked entities, they are stored in the
# entity space.
are_linked_entities = False
if main_entity is not None:
# VERTEX, ATTRIB & SEQEND are linked tags, they are NOT stored in
# the entity space.
are_linked_entities = True
if dxftype == "SEQEND":
main_entity.link_seqend(entity) # type: ignore
# Marks also the end of the main entity
main_entity = None
# Check for valid DXF structure:
# VERTEX follows POLYLINE
# ATTRIB follows INSERT
elif dxftype == expected_dxftype:
main_entity.link_entity(entity) # type: ignore
else:
raise const.DXFStructureError(
f"Expected DXF entity {dxftype} or SEQEND"
)
elif dxftype in LINKED_ENTITIES:
# Only INSERT and POLYLINE have a linked entities structure:
if dxftype == "INSERT" and not entity.dxf.get("attribs_follow", 0):
# INSERT must not have following ATTRIBS:
#
# INSERT with no ATTRIBS, attribs_follow == 0
# ATTRIB as a stand alone entity, which is a DXF structure
# error, but this error should be handled in the audit
# process.
# ....
# INSERT with ATTRIBS, attribs_follow == 1
# ATTRIB as connected entity
# SEQEND
#
# Therefore a ATTRIB following an INSERT doesn't mean that
# these entities are linked.
pass
else:
main_entity = entity
expected_dxftype = LINKED_ENTITIES[dxftype]
# Attached MTEXT entity - this feature most likely does not exist!
elif (dxftype == "MTEXT") and (entity.dxf.handle is None):
logger.error(
"Found attached MTEXT entity. Please open an issue at github: "
"https://github.com/mozman/ezdxf/issues and provide a DXF "
"example file."
)
return are_linked_entities
return entity_linker_
@@ -0,0 +1,154 @@
# Copyright (c) 2019-2022, Manfred Moitzi
# License: MIT-License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.lldxf import validator
from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2007
from ezdxf.lldxf.attributes import (
DXFAttributes,
DefSubclass,
DXFAttr,
RETURN_DEFAULT,
group_code_mapping,
)
from .dxfentity import base_class, SubclassProcessor
from .dxfobj import DXFObject
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["Sun"]
acdb_sun = DefSubclass(
"AcDbSun",
{
"version": DXFAttr(90, default=1),
"status": DXFAttr(
290,
default=1,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"color": DXFAttr(
63,
default=7,
validator=validator.is_valid_aci_color,
fixer=RETURN_DEFAULT,
),
"true_color": DXFAttr(421, default=16777215),
"intensity": DXFAttr(40, default=1),
"shadows": DXFAttr(
291,
default=1,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"julian_day": DXFAttr(91, default=2456922),
# Time in seconds past midnight:
"time": DXFAttr(92, default=43200),
"daylight_savings_time": DXFAttr(
292,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
# Shadow type:
# 0 = Ray traced shadows
# 1 = Shadow maps
"shadow_type": DXFAttr(
70,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"shadow_map_size": DXFAttr(71, default=256),
"shadow_softness": DXFAttr(280, default=1),
},
)
acdb_sun_group_codes = group_code_mapping(acdb_sun)
@register_entity
class Sun(DXFObject):
"""DXF SUN entity"""
DXFTYPE = "SUN"
DXFATTRIBS = DXFAttributes(base_class, acdb_sun)
MIN_DXF_VERSION_FOR_EXPORT = DXF2007
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(dxf, acdb_sun_group_codes, 1)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_sun.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"version",
"status",
"color",
"true_color",
"intensity",
"shadows",
"julian_day",
"time",
"daylight_savings_time",
"shadow_type",
"shadow_map_size",
"shadow_softness",
],
)
# todo: implement SUNSTUDY?
acdb_sunstudy = DefSubclass(
"AcDbSun",
{
"version": DXFAttr(90),
"name": DXFAttr(1),
"description": DXFAttr(2),
"output_type": DXFAttr(70),
"sheet_set_name": DXFAttr(
3
), # Included only if Output type is Sheet Set.
"use_subset": DXFAttr(
290
), # Included only if Output type is Sheet Set.
"sheet_subset_name": DXFAttr(4),
# Included only if Output type is Sheet Set.
"dates_from_calender": DXFAttr(291),
"date_input_array_size": DXFAttr(91),
# represents the number of dates picked
# 90 Julian day; represents the date. One entry for each date picked.
# 90 Seconds past midnight; represents the time of day. One entry for each date picked.
"range_of_dates": DXFAttr(292),
# 93 Start time. If range of dates flag is true.
# 94 End time. If range of dates flag is true.
# 95 Interval in seconds. If range of dates flag is true.
"hours_count": DXFAttr(73),
# 290 Hour. One entry for every hour as specified by the number of hours entry above.
"page_setup_wizard_handle": DXFAttr(
340
), # Page setup wizard hard pointer ID
"view_handle": DXFAttr(341), # View hard pointer ID
"visual_style_handle": DXFAttr(342), # Visual Style ID
"shade_plot_type": DXFAttr(74),
"viewports_per_page": DXFAttr(75),
"row_count": DXFAttr(76), # Number of rows for viewport distribution
"column_count": DXFAttr(77), # Number of columns for viewport distribution
"spacing": DXFAttr(40),
"lock_viewports": DXFAttr(293),
"label_viewports": DXFAttr(294),
"text_style_handle": DXFAttr(343),
},
)
acdb_sunstudy_group_codes = group_code_mapping(acdb_sunstudy)
@@ -0,0 +1,71 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.lldxf.attributes import DXFAttr, DXFAttributes, DefSubclass
from ezdxf.lldxf import const
from .dxfentity import SubclassProcessor, DXFEntity
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["TableHead"]
base_class = DefSubclass(
None,
{
"name": DXFAttr(2),
"handle": DXFAttr(5),
"owner": DXFAttr(330),
},
)
acdb_symbol_table = DefSubclass(
"AcDbSymbolTable",
{
"count": DXFAttr(70, default=0),
},
)
@register_entity
class TableHead(DXFEntity):
"""The table head structure is only maintained for export and not for
internal usage, ezdxf ignores an inconsistent table head at runtime.
"""
DXFTYPE = "TABLE"
DXFATTRIBS = DXFAttributes(base_class, acdb_symbol_table)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
dxf.name = processor.base_class.get_first_value(2)
# Stored max table count is not required:
dxf.count = 0
return dxf
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
assert self.dxf.handle, (
"TABLE needs a handle, maybe loaded from DXF R12 without handle!"
)
tagwriter.write_tag2(const.STRUCTURE_MARKER, self.DXFTYPE)
tagwriter.write_tag2(2, self.dxf.name)
if tagwriter.dxfversion >= const.DXF2000:
tagwriter.write_tag2(5, self.dxf.handle)
if self.has_extension_dict:
self.extension_dict.export_dxf(tagwriter) # type: ignore
tagwriter.write_tag2(const.OWNER_CODE, self.dxf.owner)
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_symbol_table.name)
tagwriter.write_tag2(70, self.dxf.count)
# There is always one exception:
if self.dxf.name == "DIMSTYLE":
tagwriter.write_tag2(const.SUBCLASS_MARKER, "AcDbDimStyleTable")
else: # DXF R12
# TABLE does not need a handle at all
tagwriter.write_tag2(70, self.dxf.count)
@@ -0,0 +1,49 @@
# Copyright (c) 2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING
import abc
if TYPE_CHECKING:
from ezdxf.math import Matrix44
from ezdxf.entities.dxfgfx import DXFEntity
__all__ = ["TemporaryTransformation", "TransformByBlockReference"]
class TemporaryTransformation:
__slots__ = ("_matrix",)
def __init__(self) -> None:
self._matrix: Matrix44 | None = None
def get_matrix(self) -> Matrix44 | None:
return self._matrix
def set_matrix(self, m: Matrix44 | None) -> None:
self._matrix = m
def add_matrix(self, m: Matrix44) -> None:
matrix = self.get_matrix()
if matrix is not None:
m = matrix @ m
self.set_matrix(m)
@abc.abstractmethod
def apply_transformation(self, entity: DXFEntity) -> bool: ...
class TransformByBlockReference(TemporaryTransformation):
__slots__ = ("_matrix",)
def apply_transformation(self, entity: DXFEntity) -> bool:
from ezdxf.transform import transform_entity_by_blockref
m = self.get_matrix()
if m is None:
return False
if transform_entity_by_blockref(entity, m):
self.set_matrix(None)
return True
return False
@@ -0,0 +1,483 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing_extensions import Self
import math
from ezdxf.lldxf import validator
from ezdxf.lldxf import const
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
merge_group_code_mappings,
)
from ezdxf.enums import (
TextEntityAlignment,
MAP_TEXT_ENUM_TO_ALIGN_FLAGS,
MAP_TEXT_ALIGN_FLAGS_TO_ENUM,
)
from ezdxf.math import Vec3, UVec, Matrix44, NULLVEC, Z_AXIS
from ezdxf.math.transformtools import OCSTransform
from ezdxf.audit import Auditor
from ezdxf.tools.text import plain_text
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import (
DXFGraphic,
acdb_entity,
elevation_to_z_axis,
acdb_entity_group_codes,
)
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import DXFNamespace, DXFEntity
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf import xref
__all__ = ["Text", "acdb_text", "acdb_text_group_codes"]
acdb_text = DefSubclass(
"AcDbText",
{
# First alignment point (in OCS):
"insert": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
# Text height
"height": DXFAttr(
40,
default=2.5,
validator=validator.is_greater_zero,
fixer=RETURN_DEFAULT,
),
# Text content as string:
"text": DXFAttr(
1,
default="",
validator=validator.is_valid_one_line_text,
fixer=validator.fix_one_line_text,
),
# Text rotation in degrees (optional)
"rotation": DXFAttr(50, default=0, optional=True),
# Oblique angle in degrees, vertical = 0 deg (optional)
"oblique": DXFAttr(51, default=0, optional=True),
# Text style name (optional), given text style must have an entry in the
# text-styles tables.
"style": DXFAttr(7, default="Standard", optional=True),
# Relative X scale factor—width (optional)
# This value is also adjusted when fit-type text is used
"width": DXFAttr(
41,
default=1,
optional=True,
validator=validator.is_greater_zero,
fixer=RETURN_DEFAULT,
),
# Text generation flags (optional)
# 2 = backward (mirror-x),
# 4 = upside down (mirror-y)
"text_generation_flag": DXFAttr(
71,
default=0,
optional=True,
validator=validator.is_one_of({0, 2, 4, 6}),
fixer=RETURN_DEFAULT,
),
# Horizontal text justification type (optional) horizontal justification
# 0 = Left
# 1 = Center
# 2 = Right
# 3 = Aligned (if vertical alignment = 0)
# 4 = Middle (if vertical alignment = 0)
# 5 = Fit (if vertical alignment = 0)
# This value is meaningful only if the value of a 72 or 73 group is nonzero
# (if the justification is anything other than baseline/left)
"halign": DXFAttr(
72,
default=0,
optional=True,
validator=validator.is_in_integer_range(0, 6),
fixer=RETURN_DEFAULT,
),
# Second alignment point (in OCS) (optional)
"align_point": DXFAttr(11, xtype=XType.point3d, optional=True),
# Elevation is a legacy feature from R11 and prior, do not use this
# attribute, store the entity elevation in the z-axis of the vertices.
# ezdxf does not export the elevation attribute!
"elevation": DXFAttr(38, default=0, optional=True),
# Thickness in extrusion direction, only supported for SHX font in
# AutoCAD/BricsCAD (optional), can be negative
"thickness": DXFAttr(39, default=0, optional=True),
# Extrusion direction (optional)
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
},
)
acdb_text_group_codes = group_code_mapping(acdb_text)
acdb_text2 = DefSubclass(
"AcDbText",
{
# Vertical text justification type (optional)
# 0 = Baseline
# 1 = Bottom
# 2 = Middle
# 3 = Top
"valign": DXFAttr(
73,
default=0,
optional=True,
validator=validator.is_in_integer_range(0, 4),
fixer=RETURN_DEFAULT,
)
},
)
acdb_text2_group_codes = group_code_mapping(acdb_text2)
merged_text_group_codes = merge_group_code_mappings(
acdb_entity_group_codes, # type: ignore
acdb_text_group_codes,
acdb_text2_group_codes,
)
# Formatting codes:
# %%d: '°'
# %%u in TEXT start underline formatting until next %%u or until end of line
@register_entity
class Text(DXFGraphic):
"""DXF TEXT entity"""
DXFTYPE = "TEXT"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_text, acdb_text2)
# horizontal align values
LEFT = 0
CENTER = 1
RIGHT = 2
# vertical align values
BASELINE = 0
BOTTOM = 1
MIDDLE = 2
TOP = 3
# text generation flags
MIRROR_X = 2
MIRROR_Y = 4
BACKWARD = MIRROR_X
UPSIDE_DOWN = MIRROR_Y
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
"""Loading interface. (internal API)"""
dxf = super(DXFGraphic, self).load_dxf_attribs(processor)
if processor:
processor.simple_dxfattribs_loader(dxf, merged_text_group_codes)
if processor.r12:
# Transform elevation attribute from R11 to z-axis values:
elevation_to_z_axis(dxf, ("insert", "align_point"))
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags. (internal API)"""
super().export_entity(tagwriter)
self.export_acdb_text(tagwriter)
self.export_acdb_text2(tagwriter)
def export_acdb_text(self, tagwriter: AbstractTagWriter) -> None:
"""Export TEXT data as DXF tags. (internal API)"""
if tagwriter.dxfversion > const.DXF12:
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_text.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"insert",
"height",
"text",
"thickness",
"rotation",
"oblique",
"style",
"width",
"text_generation_flag",
"halign",
"align_point",
"extrusion",
],
)
def export_acdb_text2(self, tagwriter: AbstractTagWriter) -> None:
"""Export TEXT data as DXF tags. (internal API)"""
if tagwriter.dxfversion > const.DXF12:
tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_text2.name)
self.dxf.export_dxf_attribs(tagwriter, "valign")
def set_placement(
self,
p1: UVec,
p2: Optional[UVec] = None,
align: Optional[TextEntityAlignment] = None,
) -> Text:
"""Set text alignment and location.
The alignments :attr:`ALIGNED` and :attr:`FIT`
are special, they require a second alignment point, the text is aligned
on the virtual line between these two points and sits vertically at the
baseline.
- :attr:`ALIGNED`: Text is stretched or compressed
to fit exactly between `p1` and `p2` and the text height is also
adjusted to preserve height/width ratio.
- :attr:`FIT`: Text is stretched or compressed to fit
exactly between `p1` and `p2` but only the text width is adjusted,
the text height is fixed by the :attr:`dxf.height` attribute.
- :attr:`MIDDLE`: also a special adjustment, centered
text like :attr:`MIDDLE_CENTER`, but vertically
centred at the total height of the text.
Args:
p1: first alignment point as (x, y[, z])
p2: second alignment point as (x, y[, z]), required for :attr:`ALIGNED`
and :attr:`FIT` else ignored
align: new alignment as enum :class:`~ezdxf.enums.TextEntityAlignment`,
``None`` to preserve the existing alignment.
"""
if align is None:
align = self.get_align_enum()
else:
assert isinstance(align, TextEntityAlignment)
self.set_align_enum(align)
self.dxf.insert = p1
if align in (TextEntityAlignment.ALIGNED, TextEntityAlignment.FIT):
if p2 is None:
raise const.DXFValueError(
f"Alignment '{str(align)}' requires a second alignment point."
)
else:
p2 = p1
self.dxf.align_point = p2
return self
def get_placement(self) -> tuple[TextEntityAlignment, Vec3, Optional[Vec3]]:
"""Returns a tuple (`align`, `p1`, `p2`), `align` is the alignment
enum :class:`~ezdxf.enum.TextEntityAlignment`, `p1` is the
alignment point, `p2` is only relevant if `align` is :attr:`ALIGNED` or
:attr:`FIT`, otherwise it is ``None``.
"""
p1 = Vec3(self.dxf.insert)
# Except for "LEFT" is the "align point" the real insert point:
# If the required "align point" is not present use "insert"!
p2 = Vec3(self.dxf.get("align_point", p1))
align = self.get_align_enum()
if align is TextEntityAlignment.LEFT:
return align, p1, None
if align in (TextEntityAlignment.FIT, TextEntityAlignment.ALIGNED):
return align, p1, p2
return align, p2, None
def set_align_enum(self, align=TextEntityAlignment.LEFT) -> Text:
"""Just for experts: Sets the text alignment without setting the
alignment points, set adjustment points attr:`dxf.insert` and
:attr:`dxf.align_point` manually.
Args:
align: :class:`~ezdxf.enums.TextEntityAlignment`
"""
halign, valign = MAP_TEXT_ENUM_TO_ALIGN_FLAGS[align]
self.dxf.halign = halign
self.dxf.valign = valign
return self
def get_align_enum(self) -> TextEntityAlignment:
"""Returns the current text alignment as :class:`~ezdxf.enums.TextEntityAlignment`,
see also :meth:`set_placement`.
"""
halign = self.dxf.get("halign", 0)
valign = self.dxf.get("valign", 0)
if halign > 2:
valign = 0
return MAP_TEXT_ALIGN_FLAGS_TO_ENUM.get(
(halign, valign), TextEntityAlignment.LEFT
)
def transform(self, m: Matrix44) -> Text:
"""Transform the TEXT entity by transformation matrix `m` inplace."""
dxf = self.dxf
if not dxf.hasattr("align_point"):
dxf.align_point = dxf.insert
ocs = OCSTransform(self.dxf.extrusion, m)
dxf.insert = ocs.transform_vertex(dxf.insert)
dxf.align_point = ocs.transform_vertex(dxf.align_point)
old_rotation = dxf.rotation
new_rotation = ocs.transform_deg_angle(old_rotation)
x_scale = ocs.transform_length(Vec3.from_deg_angle(old_rotation))
y_scale = ocs.transform_length(Vec3.from_deg_angle(old_rotation + 90.0))
if not ocs.scale_uniform:
oblique_vec = Vec3.from_deg_angle(old_rotation + 90.0 - dxf.oblique)
new_oblique_deg = (
new_rotation
+ 90.0
- ocs.transform_direction(oblique_vec).angle_deg
)
dxf.oblique = new_oblique_deg
y_scale *= math.cos(math.radians(new_oblique_deg))
dxf.width *= x_scale / y_scale
dxf.height *= y_scale
dxf.rotation = new_rotation
if dxf.hasattr("thickness"): # can be negative
dxf.thickness = ocs.transform_thickness(dxf.thickness)
dxf.extrusion = ocs.new_extrusion
self.post_transform(m)
return self
def translate(self, dx: float, dy: float, dz: float) -> Text:
"""Optimized TEXT/ATTRIB/ATTDEF translation about `dx` in x-axis, `dy`
in y-axis and `dz` in z-axis, returns `self`.
"""
ocs = self.ocs()
dxf = self.dxf
vec = Vec3(dx, dy, dz)
dxf.insert = ocs.from_wcs(vec + ocs.to_wcs(dxf.insert))
if dxf.hasattr("align_point"):
dxf.align_point = ocs.from_wcs(vec + ocs.to_wcs(dxf.align_point))
# Avoid Matrix44 instantiation if not required:
if self.is_post_transform_required:
self.post_transform(Matrix44.translate(dx, dy, dz))
return self
def remove_dependencies(self, other: Optional[Drawing] = None) -> None:
"""Remove all dependencies from actual document.
(internal API)
"""
if not self.is_alive:
return
super().remove_dependencies()
has_style = other is not None and (self.dxf.style in other.styles)
if not has_style:
self.dxf.style = "Standard"
def register_resources(self, registry: xref.Registry) -> None:
"""Register required resources to the resource registry."""
super().register_resources(registry)
if self.dxf.hasattr("style"):
registry.add_text_style(self.dxf.style)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
"""Translate resources from self to the copied entity."""
super().map_resources(clone, mapping)
if clone.dxf.hasattr("style"):
clone.dxf.style = mapping.get_text_style(clone.dxf.style)
def plain_text(self) -> str:
"""Returns text content without formatting codes."""
return plain_text(self.dxf.text)
def audit(self, auditor: Auditor):
"""Validity check."""
super().audit(auditor)
auditor.check_text_style(self)
@property
def is_backward(self) -> bool:
"""Get/set text generation flag BACKWARDS, for mirrored text along the
x-axis.
"""
return bool(self.dxf.text_generation_flag & const.BACKWARD)
@is_backward.setter
def is_backward(self, state) -> None:
self.set_flag_state(const.BACKWARD, state, "text_generation_flag")
@property
def is_upside_down(self) -> bool:
"""Get/set text generation flag UPSIDE_DOWN, for mirrored text along
the y-axis.
"""
return bool(self.dxf.text_generation_flag & const.UPSIDE_DOWN)
@is_upside_down.setter
def is_upside_down(self, state) -> None:
self.set_flag_state(const.UPSIDE_DOWN, state, "text_generation_flag")
def wcs_transformation_matrix(self) -> Matrix44:
return text_transformation_matrix(self)
def font_name(self) -> str:
"""Returns the font name of the associated :class:`Textstyle`."""
font_name = "arial.ttf"
style_name = self.dxf.style
if self.doc:
try:
style = self.doc.styles.get(style_name)
font_name = style.dxf.font
except ValueError:
pass
return font_name
def fit_length(self) -> float:
"""Returns the text length for alignments :attr:`TextEntityAlignment.FIT`
and :attr:`TextEntityAlignment.ALIGNED`, defined by the distance from
the insertion point to the align point or 0 for all other alignments.
"""
length = 0.0
align, p1, p2 = self.get_placement()
if align in (TextEntityAlignment.FIT, TextEntityAlignment.ALIGNED):
# text is stretch between p1 and p2
length = p1.distance(p2)
return length
def text_transformation_matrix(entity: Text) -> Matrix44:
"""Apply rotation, width factor, translation to the insertion point
and if necessary transformation from OCS to WCS.
"""
angle = math.radians(entity.dxf.rotation)
width_factor = entity.dxf.width
align, p1, p2 = entity.get_placement()
mirror_x = -1 if entity.is_backward else 1
mirror_y = -1 if entity.is_upside_down else 1
oblique = math.radians(entity.dxf.oblique)
location = p1
if align in (TextEntityAlignment.ALIGNED, TextEntityAlignment.FIT):
width_factor = 1.0 # text goes from p1 to p2, no stretching applied
location = p1.lerp(p2, factor=0.5)
angle = (p2 - p1).angle # override stored angle
m = Matrix44()
if oblique:
m *= Matrix44.shear_xy(angle_x=oblique)
sx = width_factor * mirror_x
sy = mirror_y
if sx != 1 or sy != 1:
m *= Matrix44.scale(sx, sy, 1)
if angle:
m *= Matrix44.z_rotate(angle)
if location:
m *= Matrix44.translate(location.x, location.y, location.z)
ocs = entity.ocs()
if ocs.transform: # to WCS
m *= ocs.matrix
return m
@@ -0,0 +1,248 @@
# Copyright (c) 2019-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import logging
from ezdxf.lldxf import validator, const
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER
from ezdxf.entities.dxfentity import base_class, SubclassProcessor, DXFEntity
from ezdxf.entities.layer import acdb_symbol_table_record
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.fonts import fonts
__all__ = ["Textstyle"]
logger = logging.getLogger("ezdxf")
acdb_style = DefSubclass(
"AcDbTextStyleTableRecord",
{
"name": DXFAttr(
2,
default="Standard",
validator=validator.is_valid_table_name,
),
# Flags: Standard flag values (bit-coded values):
# 1 = If set, this entry describes a shape
# 4 = Vertical text
# 16 = If set, table entry is externally dependent on a xref
# 32 = If both this bit and bit 16 are set, the externally dependent xref ...
# 64 = If set, the table entry was referenced by at least one entity in ...
# Vertical text works only for SHX fonts in AutoCAD and BricsCAD
"flags": DXFAttr(70, default=0),
# Fixed height, 0 if not fixed
"height": DXFAttr(
40,
default=0,
validator=validator.is_greater_or_equal_zero,
fixer=RETURN_DEFAULT,
),
# Width factor: a.k.a. "Stretch"
"width": DXFAttr(
41,
default=1,
validator=validator.is_greater_zero,
fixer=RETURN_DEFAULT,
),
# Oblique angle in degree, 0 = vertical
"oblique": DXFAttr(50, default=0),
# Generation flags:
# 2 = backward
# 4 = mirrored in Y
"generation_flags": DXFAttr(71, default=0),
# Last height used:
"last_height": DXFAttr(42, default=2.5),
# Primary font file name:
# ATTENTION: The font file name can be an empty string and the font family
# may be stored in XDATA! See also posts at the (unrelated) issue #380.
"font": DXFAttr(3, default=const.DEFAULT_TEXT_FONT),
# Big font name, blank if none
"bigfont": DXFAttr(4, default=""),
},
)
acdb_style_group_codes = group_code_mapping(acdb_style)
# XDATA: This is not a reliable source for font data!
# 1001 <ctrl> ACAD
# 1000 <str> Arial ; font-family sometimes an empty string!
# 1071 <int> 34 ; flags
# ----
# "Arial" "normal" flags = 34 = 0b00:00000000:00000000:00100010
# "Arial" "italic" flags = 16777250 = 0b01:00000000:00000000:00100010
# "Arial" "bold" flags = 33554466 = 0b10:00000000:00000000:00100010
# "Arial" "bold+italic" flags = 50331682 = 0b11:00000000:00000000:00100010
@register_entity
class Textstyle(DXFEntity):
"""DXF STYLE entity"""
DXFTYPE = "STYLE"
DXFATTRIBS = DXFAttributes(base_class, acdb_symbol_table_record, acdb_style)
ITALIC = 0b01000000000000000000000000
BOLD = 0b10000000000000000000000000
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.simple_dxfattribs_loader(dxf, acdb_style_group_codes) # type: ignore
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_symbol_table_record.name)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_style.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"name",
"flags",
"height",
"width",
"oblique",
"generation_flags",
"last_height",
"font",
"bigfont",
],
)
@property
def has_extended_font_data(self) -> bool:
"""Returns ``True`` if extended font data is present."""
return self.has_xdata("ACAD")
def get_extended_font_data(self) -> tuple[str, bool, bool]:
"""Returns extended font data as tuple (font-family, italic-flag,
bold-flag).
The extended font data is optional and not reliable! Returns
("", ``False``, ``False``) if extended font data is not present.
"""
family = ""
italic = False
bold = False
try:
xdata = self.get_xdata("ACAD")
except const.DXFValueError:
pass
else:
if len(xdata) > 1:
group_code, value = xdata[0]
if group_code == 1000:
family = value
group_code, value = xdata[1]
if group_code == 1071:
italic = bool(self.ITALIC & value)
bold = bool(self.BOLD & value)
return family, italic, bold
def set_extended_font_data(
self, family: str = "", *, italic=False, bold=False
) -> None:
"""Set extended font data, the font-family name `family` is not
validated by `ezdxf`. Overwrites existing data.
"""
if self.has_xdata("ACAD"):
self.discard_xdata("ACAD")
flags = 34 # unknown default flags
if italic:
flags += self.ITALIC
if bold:
flags += self.BOLD
self.set_xdata("ACAD", [(1000, family), (1071, flags)])
def discard_extended_font_data(self):
"""Discard extended font data."""
self.discard_xdata("ACAD")
@property
def is_backward(self) -> bool:
"""Get/set text generation flag BACKWARDS, for mirrored text along the
x-axis.
"""
return self.get_flag_state(const.BACKWARD, "generation_flags")
@is_backward.setter
def is_backward(self, state) -> None:
self.set_flag_state(const.BACKWARD, state, "generation_flags")
@property
def is_upside_down(self) -> bool:
"""Get/set text generation flag UPSIDE_DOWN, for mirrored text along
the y-axis.
"""
return self.get_flag_state(const.UPSIDE_DOWN, "generation_flags")
@is_upside_down.setter
def is_upside_down(self, state) -> None:
self.set_flag_state(const.UPSIDE_DOWN, state, "generation_flags")
@property
def is_vertical_stacked(self) -> bool:
"""Get/set style flag VERTICAL_STACKED, for vertical stacked text."""
return self.get_flag_state(const.VERTICAL_STACKED, "flags")
@is_vertical_stacked.setter
def is_vertical_stacked(self, state) -> None:
self.set_flag_state(const.VERTICAL_STACKED, state, "flags")
@property
def is_shape_file(self) -> bool:
"""``True`` if entry describes a shape."""
return self.dxf.name == "" and bool(self.dxf.flags & 1)
def make_font(
self,
cap_height: Optional[float] = None,
width_factor: Optional[float] = None,
) -> fonts.AbstractFont:
"""Returns a font abstraction :class:`~ezdxf.tools.fonts.AbstractFont`
for this text style. Returns a font for a cap height of 1, if the
text style has auto height (:attr:`Textstyle.dxf.height` is 0) and
the given `cap_height` is ``None`` or 0.
Uses the :attr:`Textstyle.dxf.width` attribute if the given `width_factor`
is ``None`` or 0, the default value is 1.
The attribute :attr:`Textstyle.dxf.big_font` is ignored.
"""
from ezdxf.fonts import fonts
ttf = ""
if self.has_extended_font_data:
family, italic, bold = self.get_extended_font_data()
if family:
text_style = "Italic" if italic else "Regular"
text_weight = 700 if bold else 400
font_face = fonts.FontFace(
family=family, style=text_style, weight=text_weight
)
ttf = fonts.find_font_file_name(font_face)
else:
ttf = self.dxf.get("font", const.DEFAULT_TTF)
if ttf == "":
ttf = const.DEFAULT_TTF
if cap_height is None or cap_height == 0.0:
cap_height = self.dxf.height
if cap_height == 0.0:
cap_height = 1.0
if width_factor is None or width_factor == 0.0:
width_factor = self.dxf.width
return fonts.make_font(ttf, cap_height, width_factor) # type: ignore
@@ -0,0 +1,105 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing_extensions import Self
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2000
from ezdxf.math import NULLVEC, Z_AXIS, X_AXIS
from ezdxf.math.transformtools import transform_extrusion
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.math import Matrix44
from ezdxf import xref
__all__ = ["Tolerance"]
acdb_tolerance = DefSubclass(
"AcDbFcf",
{
"dimstyle": DXFAttr(
3,
default="Standard",
validator=validator.is_valid_table_name,
),
# Insertion point (in WCS):
"insert": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
# String representing the visual representation of the tolerance:
"content": DXFAttr(1, default=""),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# X-axis direction vector (in WCS):
"x_axis_vector": DXFAttr(
11,
xtype=XType.point3d,
default=X_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
},
)
acdb_tolerance_group_codes = group_code_mapping(acdb_tolerance)
@register_entity
class Tolerance(DXFGraphic):
"""DXF TOLERANCE entity"""
DXFTYPE = "TOLERANCE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_tolerance)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_tolerance_group_codes, subclass=2, recover=True
)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_tolerance.name)
self.dxf.export_dxf_attribs(
tagwriter,
["dimstyle", "insert", "content", "extrusion", "x_axis_vector"],
)
def register_resources(self, registry: xref.Registry) -> None:
super().register_resources(registry)
registry.add_dim_style(self.dxf.dimstyle)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
super().map_resources(clone, mapping)
clone.dxf.dimstyle = mapping.get_dim_style(self.dxf.dimstyle)
def transform(self, m: Matrix44) -> Tolerance:
"""Transform the TOLERANCE entity by transformation matrix `m` inplace."""
self.dxf.insert = m.transform(self.dxf.insert)
self.dxf.x_axis_vector = m.transform_direction(self.dxf.x_axis_vector)
self.dxf.extrusion, _ = transform_extrusion(self.dxf.extrusion, m)
self.post_transform(m)
return self
@@ -0,0 +1,85 @@
# Copyright (c) 2019-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import logging
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER
from ezdxf.lldxf import validator
from ezdxf.math import UCS, NULLVEC, X_AXIS, Y_AXIS
from ezdxf.entities.dxfentity import base_class, SubclassProcessor, DXFEntity
from ezdxf.entities.layer import acdb_symbol_table_record
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["UCSTableEntry"]
logger = logging.getLogger("ezdxf")
acdb_ucs = DefSubclass(
"AcDbUCSTableRecord",
{
"name": DXFAttr(2, validator=validator.is_valid_table_name),
"flags": DXFAttr(70, default=0),
"origin": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
"xaxis": DXFAttr(
11,
xtype=XType.point3d,
default=X_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
"yaxis": DXFAttr(
12,
xtype=XType.point3d,
default=Y_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
},
)
acdb_ucs_group_codes = group_code_mapping(acdb_ucs)
@register_entity
class UCSTableEntry(DXFEntity):
"""DXF UCS table entity"""
DXFTYPE = "UCS"
DXFATTRIBS = DXFAttributes(base_class, acdb_symbol_table_record, acdb_ucs)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.simple_dxfattribs_loader(dxf, acdb_ucs_group_codes) # type: ignore
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_symbol_table_record.name)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_ucs.name)
self.dxf.export_dxf_attribs(
tagwriter, ["name", "flags", "origin", "xaxis", "yaxis"]
)
def ucs(self) -> UCS:
"""Returns an :class:`ezdxf.math.UCS` object for this UCS table entry."""
return UCS(
origin=self.dxf.origin,
ux=self.dxf.xaxis,
uy=self.dxf.yaxis,
)
@@ -0,0 +1,446 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Union, Iterable, Optional
from typing_extensions import Self
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2000, DXFTypeError
from ezdxf.lldxf import const
from ezdxf.lldxf.tags import Tags
from ezdxf.math import NULLVEC, Z_AXIS, UVec, Matrix44, Vec3
from .dxfentity import base_class, SubclassProcessor, DXFEntity
from .dxfgfx import DXFGraphic, acdb_entity
from .dxfobj import DXFObject
from .factory import register_entity
from .copy import default_copy
from ezdxf.math.transformtools import (
InsertTransformationError,
InsertCoordinateSystem,
)
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf import xref
__all__ = [
"PdfUnderlay",
"DwfUnderlay",
"DgnUnderlay",
"PdfDefinition",
"DgnDefinition",
"DwfDefinition",
"Underlay",
"UnderlayDefinition",
]
acdb_underlay = DefSubclass(
"AcDbUnderlayReference",
{
# Hard reference to underlay definition object
"underlay_def_handle": DXFAttr(340),
"insert": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
# Scale x factor:
"scale_x": DXFAttr(
41,
default=1,
validator=validator.is_not_zero,
fixer=RETURN_DEFAULT,
),
# Scale y factor:
"scale_y": DXFAttr(
42,
default=1,
validator=validator.is_not_zero,
fixer=RETURN_DEFAULT,
),
# Scale z factor:
"scale_z": DXFAttr(
43,
default=1,
validator=validator.is_not_zero,
fixer=RETURN_DEFAULT,
),
# Rotation angle in degrees:
"rotation": DXFAttr(50, default=0),
"extrusion": DXFAttr(
210,
xtype=XType.point3d,
default=Z_AXIS,
optional=True,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# Underlay display properties:
# 1 = Clipping is on
# 2 = Underlay is on
# 4 = Monochrome
# 8 = Adjust for background
"flags": DXFAttr(280, default=10),
# Contrast value (20-100; default = 100)
"contrast": DXFAttr(
281,
default=100,
validator=validator.is_in_integer_range(20, 101),
fixer=validator.fit_into_integer_range(20, 101),
),
# Fade value (0-80; default = 0)
"fade": DXFAttr(
282,
default=0,
validator=validator.is_in_integer_range(0, 81),
fixer=validator.fit_into_integer_range(0, 81),
),
},
)
acdb_underlay_group_codes = group_code_mapping(acdb_underlay)
class Underlay(DXFGraphic):
"""Virtual UNDERLAY entity."""
# DXFTYPE = 'UNDERLAY'
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_underlay)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
def __init__(self) -> None:
super().__init__()
self._boundary_path: list[UVec] = []
self._underlay_def: Optional[UnderlayDefinition] = None
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, Underlay)
entity._boundary_path = list(self._boundary_path)
entity._underlay_def = self._underlay_def
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.subclass_by_index(2)
if tags:
tags = Tags(self.load_boundary_path(tags))
processor.fast_load_dxfattribs(
dxf, acdb_underlay_group_codes, subclass=tags
)
if len(self.boundary_path) < 2:
self.dxf = dxf
self.reset_boundary_path()
else:
raise const.DXFStructureError(
f"missing 'AcDbUnderlayReference' subclass in "
f"{self.DXFTYPE}(#{dxf.handle})"
)
return dxf
def load_boundary_path(self, tags: Tags) -> Iterable:
path = []
for tag in tags:
if tag.code == 11:
path.append(tag.value)
else:
yield tag
self._boundary_path = path
def post_load_hook(self, doc: Drawing) -> None:
super().post_load_hook(doc)
db = doc.entitydb
self._underlay_def = db.get(self.dxf.get("underlay_def_handle", None)) # type: ignore
def post_bind_hook(self):
assert isinstance(self.dxf.handle, str)
underlay_def = self._underlay_def
if (
isinstance(underlay_def, UnderlayDefinition)
and self.doc is underlay_def.doc # it's not a xref copy!
):
underlay_def.append_reactor_handle(self.dxf.handle)
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_underlay.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"underlay_def_handle",
"insert",
"scale_x",
"scale_y",
"scale_z",
"rotation",
"extrusion",
"flags",
"contrast",
"fade",
],
)
self.export_boundary_path(tagwriter)
def export_boundary_path(self, tagwriter: AbstractTagWriter):
for vertex in self.boundary_path:
tagwriter.write_vertex(11, vertex[:2])
def register_resources(self, registry: xref.Registry) -> None:
super().register_resources(registry)
if isinstance(self._underlay_def, UnderlayDefinition):
registry.add_handle(self._underlay_def.dxf.handle)
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
assert isinstance(clone, Underlay)
super().map_resources(clone, mapping)
underlay_def_copy = self.map_underlay_def(clone, mapping)
clone._underlay_def = underlay_def_copy
clone.dxf.underlay_def_handle = underlay_def_copy.dxf.handle
underlay_def_copy.append_reactor_handle(clone.dxf.handle)
def map_underlay_def(
self, clone: Underlay, mapping: xref.ResourceMapper
) -> UnderlayDefinition:
underlay_def = self._underlay_def
assert isinstance(underlay_def, UnderlayDefinition)
underlay_def_copy = mapping.get_reference_of_copy(underlay_def.dxf.handle)
assert isinstance(underlay_def_copy, UnderlayDefinition)
doc = clone.doc
assert doc is not None
underlay_dict = doc.rootdict.get_required_dict(underlay_def.acad_dict_name)
if underlay_dict.find_key(underlay_def_copy): # entry already exist
return underlay_def_copy
# create required dictionary entry
key = doc.objects.next_underlay_key(lambda k: k not in underlay_dict)
underlay_dict.take_ownership(key, underlay_def_copy)
return underlay_def_copy
def set_underlay_def(self, underlay_def: UnderlayDefinition) -> None:
self._underlay_def = underlay_def
self.dxf.underlay_def_handle = underlay_def.dxf.handle
underlay_def.append_reactor_handle(self.dxf.handle)
def get_underlay_def(self) -> Optional[UnderlayDefinition]:
return self._underlay_def
@property
def boundary_path(self):
return self._boundary_path
@boundary_path.setter
def boundary_path(self, vertices: Iterable[UVec]) -> None:
self.set_boundary_path(vertices)
@property
def clipping(self) -> bool:
return bool(self.dxf.flags & const.UNDERLAY_CLIPPING)
@clipping.setter
def clipping(self, state: bool) -> None:
self.set_flag_state(const.UNDERLAY_CLIPPING, state)
@property
def on(self) -> bool:
return bool(self.dxf.flags & const.UNDERLAY_ON)
@on.setter
def on(self, state: bool) -> None:
self.set_flag_state(const.UNDERLAY_ON, state)
@property
def monochrome(self) -> bool:
return bool(self.dxf.flags & const.UNDERLAY_MONOCHROME)
@monochrome.setter
def monochrome(self, state: bool) -> None:
self.set_flag_state(const.UNDERLAY_MONOCHROME, state)
@property
def adjust_for_background(self) -> bool:
return bool(self.dxf.flags & const.UNDERLAY_ADJUST_FOR_BG)
@adjust_for_background.setter
def adjust_for_background(self, state: bool):
self.set_flag_state(const.UNDERLAY_ADJUST_FOR_BG, state)
@property
def scaling(self) -> tuple[float, float, float]:
return self.dxf.scale_x, self.dxf.scale_y, self.dxf.scale_z
@scaling.setter
def scaling(self, scale: Union[float, tuple]):
if isinstance(scale, (float, int)):
x, y, z = scale, scale, scale
else:
x, y, z = scale
self.dxf.scale_x = x
self.dxf.scale_y = y
self.dxf.scale_z = z
def set_boundary_path(self, vertices: Iterable[UVec]) -> None:
# path coordinates as drawing coordinates but unscaled
vertices = list(vertices)
if len(vertices):
self._boundary_path = vertices
self.clipping = True
else:
self.reset_boundary_path()
def reset_boundary_path(self) -> None:
"""Removes the clipping path."""
self._boundary_path = []
self.clipping = False
def destroy(self) -> None:
if not self.is_alive:
return
if self._underlay_def:
self._underlay_def.discard_reactor_handle(self.dxf.handle)
del self._boundary_path
super().destroy()
def transform(self, m: Matrix44) -> Underlay:
"""Transform UNDERLAY entity by transformation matrix `m` inplace.
Unlike the transformation matrix `m`, the UNDERLAY entity can not
represent a non-orthogonal target coordinate system and an
:class:`InsertTransformationError` will be raised in that case.
"""
dxf = self.dxf
source_system = InsertCoordinateSystem(
insert=Vec3(dxf.insert),
scale=(dxf.scale_x, dxf.scale_y, dxf.scale_z),
rotation=dxf.rotation,
extrusion=dxf.extrusion,
)
try:
target_system = source_system.transform(m)
except InsertTransformationError:
raise InsertTransformationError(
"UNDERLAY entity can not represent a non-orthogonal target coordinate system."
)
dxf.insert = target_system.insert
dxf.rotation = target_system.rotation
dxf.extrusion = target_system.extrusion
dxf.scale_x = target_system.scale_factor_x
dxf.scale_y = target_system.scale_factor_y
dxf.scale_z = target_system.scale_factor_z
self.post_transform(m)
return self
@register_entity
class PdfUnderlay(Underlay):
"""DXF PDFUNDERLAY entity"""
DXFTYPE = "PDFUNDERLAY"
@register_entity
class PdfReference(Underlay):
"""PDFREFERENCE ia a synonym for PDFUNDERLAY, ezdxf creates always PDFUNDERLAY
entities.
"""
DXFTYPE = "PDFREFERENCE"
@register_entity
class DwfUnderlay(Underlay):
"""DXF DWFUNDERLAY entity"""
DXFTYPE = "DWFUNDERLAY"
@register_entity
class DgnUnderlay(Underlay):
"""DXF DGNUNDERLAY entity"""
DXFTYPE = "DGNUNDERLAY"
acdb_underlay_def = DefSubclass(
"AcDbUnderlayDefinition",
{
"filename": DXFAttr(1), # File name of underlay
"name": DXFAttr(2),
# underlay name - pdf=page number to display; dgn=default; dwf=????
},
)
acdb_underlay_def_group_codes = group_code_mapping(acdb_underlay_def)
# (PDF|DWF|DGN)DEFINITION - requires entry in objects table ACAD_(PDF|DWF|DGN)DEFINITIONS,
# ACAD_(PDF|DWF|DGN)DEFINITIONS do not exist by default
class UnderlayDefinition(DXFObject):
"""Virtual UNDERLAY DEFINITION entity."""
DXFTYPE = "UNDERLAYDEFINITION"
DXFATTRIBS = DXFAttributes(base_class, acdb_underlay_def)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_underlay_def_group_codes, subclass=1
)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_underlay_def.name)
self.dxf.export_dxf_attribs(tagwriter, ["filename", "name"])
@property
def file_format(self) -> str:
return self.DXFTYPE[:3]
@property
def entity_name(self) -> str:
return self.file_format + "UNDERLAY"
@property
def acad_dict_name(self) -> str:
return f"ACAD_{self.file_format}DEFINITIONS"
def post_new_hook(self):
self.set_reactors([self.dxf.owner])
@register_entity
class PdfDefinition(UnderlayDefinition):
"""DXF PDFDEFINITION entity"""
DXFTYPE = "PDFDEFINITION"
@register_entity
class DwfDefinition(UnderlayDefinition):
"""DXF DWFDEFINITION entity"""
DXFTYPE = "DWFDEFINITION"
@register_entity
class DgnDefinition(UnderlayDefinition):
"""DXF DGNDEFINITION entity"""
DXFTYPE = "DGNDEFINITION"
@@ -0,0 +1,176 @@
# Copyright (c) 2019-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import logging
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER, DXF2000, DXF2007, DXF2010
from ezdxf.math import Vec3, NULLVEC
from ezdxf.entities.dxfentity import base_class, SubclassProcessor, DXFEntity
from ezdxf.entities.layer import acdb_symbol_table_record
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["View"]
logger = logging.getLogger("ezdxf")
acdb_view = DefSubclass(
"AcDbViewTableRecord",
{
"name": DXFAttr(2, validator=validator.is_valid_table_name),
"flags": DXFAttr(70, default=0),
"height": DXFAttr(40, default=1),
"width": DXFAttr(41, default=1),
"center": DXFAttr(10, xtype=XType.point2d, default=NULLVEC),
"direction": DXFAttr(
11,
xtype=XType.point3d,
default=Vec3(1, 1, 1),
validator=validator.is_not_null_vector,
),
"target": DXFAttr(12, xtype=XType.point3d, default=NULLVEC),
"focal_length": DXFAttr(42, default=50),
"front_clipping": DXFAttr(43, default=0),
"back_clipping": DXFAttr(44, default=0),
"view_twist": DXFAttr(50, default=0),
"view_mode": DXFAttr(71, default=0),
# Render mode:
# 0 = 2D Optimized (classic 2D)
# 1 = Wireframe
# 2 = Hidden line
# 3 = Flat shaded
# 4 = Gouraud shaded
# 5 = Flat shaded with wireframe
# 6 = Gouraud shaded with wireframe
"render_mode": DXFAttr(
281,
default=0,
dxfversion=DXF2000,
validator=validator.is_in_integer_range(0, 7),
fixer=RETURN_DEFAULT,
),
# 1 if there is an UCS associated to this view, 0 otherwise.
"ucs": DXFAttr(
72,
default=0,
dxfversion=DXF2000,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"ucs_origin": DXFAttr(110, xtype=XType.point3d, dxfversion=DXF2000),
"ucs_xaxis": DXFAttr(
111,
xtype=XType.point3d,
dxfversion=DXF2000,
validator=validator.is_not_null_vector,
),
"ucs_yaxis": DXFAttr(
112,
xtype=XType.point3d,
dxfversion=DXF2000,
validator=validator.is_not_null_vector,
),
# 0 = UCS is not orthographic
# 1 = Top
# 2 = Bottom
# 3 = Front
# 4 = Back
# 5 = Left
# 6 = Right
"ucs_ortho_type": DXFAttr(
79,
dxfversion=DXF2000,
validator=validator.is_in_integer_range(0, 7),
fixer=lambda x: 0,
),
"elevation": DXFAttr(146, dxfversion=DXF2000, default=0),
# handle of AcDbUCSTableRecord if UCS is a named UCS. If not present,
# then UCS is unnamed:
"ucs_handle": DXFAttr(345, dxfversion=DXF2000),
# handle of AcDbUCSTableRecord of base UCS if UCS is orthographic (79 code
# is non-zero). If not present and 79 code is non-zero, then base UCS is
# taken to be WORLD
"base_ucs_handle": DXFAttr(346, dxfversion=DXF2000),
# 1 if the camera is plottable
"camera_plottable": DXFAttr(
73,
default=0,
dxfversion=DXF2007,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"background_handle": DXFAttr(332, optional=True, dxfversion=DXF2007),
"live_selection_handle": DXFAttr(
334, optional=True, dxfversion=DXF2007
),
"visual_style_handle": DXFAttr(348, optional=True, dxfversion=DXF2007),
"sun_handle": DXFAttr(361, optional=True, dxfversion=DXF2010),
},
)
acdb_view_group_codes = group_code_mapping(acdb_view)
@register_entity
class View(DXFEntity):
"""DXF VIEW entity"""
DXFTYPE = "VIEW"
DXFATTRIBS = DXFAttributes(base_class, acdb_symbol_table_record, acdb_view)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.simple_dxfattribs_loader(dxf, acdb_view_group_codes) # type: ignore
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
if tagwriter.dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_symbol_table_record.name)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_view.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"name",
"flags",
"height",
"width",
"center",
"direction",
"target",
"focal_length",
"front_clipping",
"back_clipping",
"view_twist",
"view_mode",
"render_mode",
"ucs",
"ucs_origin",
"ucs_xaxis",
"ucs_yaxis",
"ucs_ortho_type",
"elevation",
"ucs_handle",
"base_ucs_handle",
"camera_plottable",
"background_handle",
"live_selection_handle",
"visual_style_handle",
"sun_handle",
],
)
@@ -0,0 +1,681 @@
# Copyright (c) 2019-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Optional
from typing_extensions import Self
import math
from ezdxf.lldxf import validator
from ezdxf.lldxf import const
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.types import DXFTag, DXFVertex
from ezdxf.lldxf.tags import Tags
from ezdxf.lldxf.const import (
DXF12,
SUBCLASS_MARKER,
DXFStructureError,
DXFValueError,
DXFTableEntryError,
)
from ezdxf.math import (
Vec3,
Vec2,
NULLVEC,
X_AXIS,
Y_AXIS,
Z_AXIS,
Matrix44,
BoundingBox2d,
)
from ezdxf.tools import set_flag_state
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity
from .factory import register_entity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import DXFNamespace, DXFEntity
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf import xref
__all__ = ["Viewport"]
acdb_viewport = DefSubclass(
"AcDbViewport",
{
# DXF reference: Center point (in WCS)
# Correction to the DXF reference:
# This point represents the center of the viewport in paper space units
# (DCS), but is stored as 3D point inclusive z-axis!
"center": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
# Width in paper space units:
"width": DXFAttr(40, default=1),
# Height in paper space units:
"height": DXFAttr(41, default=1),
# Viewport status field: (according to the DXF Reference)
# -1 = On, but is fully off-screen, or is one of the viewports that is not
# active because the $MAXACTVP count is currently being exceeded.
# 0 = Off
# <positive value> = On and active. The value indicates the order of
# stacking for the viewports, where 1 is the "active" viewport, 2 is the
# next, and so on. The "active" viewport determines how the paperspace layout
# is presented as a whole (location & zoom state)
"status": DXFAttr(68, default=0),
# Viewport id: (according to the DXF Reference)
# Special VIEWPORT id == 1, this viewport defines the area of the layout
# which is currently shown in the layout tab by the CAD application.
# I guess this is meant by "active viewport" and therefore it is most likely
# that this id is always 1.
# This "active viewport" is mandatory for a valid DXF file.
# BricsCAD set this id to -1 if the viewport is off and 'status' (group code 68)
# is not present.
"id": DXFAttr(69, default=2),
# DXF reference: View center point (in WCS):
# Correction to the DXF reference:
# This point represents the center point in model space (WCS) stored as
# 2D point!
"view_center_point": DXFAttr(12, xtype=XType.point2d, default=NULLVEC),
"snap_base_point": DXFAttr(13, xtype=XType.point2d, default=NULLVEC),
"snap_spacing": DXFAttr(14, xtype=XType.point2d, default=Vec2(10, 10)),
"grid_spacing": DXFAttr(15, xtype=XType.point2d, default=Vec2(10, 10)),
# View direction vector (WCS):
"view_direction_vector": DXFAttr(16, xtype=XType.point3d, default=Z_AXIS),
# View target point (in WCS):
"view_target_point": DXFAttr(17, xtype=XType.point3d, default=NULLVEC),
"perspective_lens_length": DXFAttr(42, default=50),
"front_clip_plane_z_value": DXFAttr(43, default=0),
"back_clip_plane_z_value": DXFAttr(44, default=0),
# View height (in model space units):
"view_height": DXFAttr(45, default=1),
"snap_angle": DXFAttr(50, default=0),
"view_twist_angle": DXFAttr(51, default=0),
"circle_zoom": DXFAttr(72, default=100),
# 331: Frozen layer object ID/handle (multiple entries may exist) (optional)
# Viewport status bit-coded flags:
# 1 (0x1) = Enables perspective mode
# 2 (0x2) = Enables front clipping
# 4 (0x4) = Enables back clipping
# 8 (0x8) = Enables UCS follow
# 16 (0x10) = Enables front clip not at eye
# 32 (0x20) = Enables UCS icon visibility
# 64 (0x40) = Enables UCS icon at origin
# 128 (0x80) = Enables fast zoom
# 256 (0x100) = Enables snap mode
# 512 (0x200) = Enables grid mode
# 1024 (0x400) = Enables isometric snap style
# 2048 (0x800) = Enables hide plot mode
# 4096 (0x1000) = kIsoPairTop. If set and kIsoPairRight is not set, then
# isopair top is enabled. If both kIsoPairTop and kIsoPairRight are set,
# then isopair left is enabled
# 8192 (0x2000) = kIsoPairRight. If set and kIsoPairTop is not set, then
# isopair right is enabled
# 16384 (0x4000) = Enables viewport zoom locking
# 32768 (0x8000) = Currently always enabled
# 65536 (0x10000) = Enables non-rectangular clipping
# 131072 (0x20000) = Turns the viewport off
# 262144 (0x40000) = Enables the display of the grid beyond the drawing
# limits
# 524288 (0x80000) = Enable adaptive grid display
# 1048576 (0x100000) = Enables subdivision of the grid below the set grid
# spacing when the grid display is adaptive
# 2097152 (0x200000) = Enables grid follows workplane switching
"flags": DXFAttr(90, default=0),
# Clipping viewports: the following handle point to a graphical entity
# located in the paperspace. Known supported entities:
# LWPOLYLINE (2D POLYLINE), CIRCLE, ELLIPSE, closed SPLINE
# Extract bounding- or clipping path: ezdxf.render.make_path()
"clipping_boundary_handle": DXFAttr(340, default="0", optional=True),
# Plot style sheet name assigned to this viewport
"plot_style_name": DXFAttr(1, default=""),
# Render mode:
# 0 = 2D Optimized (classic 2D)
# 1 = Wireframe
# 2 = Hidden line
# 3 = Flat shaded
# 4 = Gouraud shaded
# 5 = Flat shaded with wireframe
# 6 = Gouraud shaded with wireframe
"render_mode": DXFAttr(
281,
default=0,
validator=validator.is_in_integer_range(0, 7),
fixer=RETURN_DEFAULT,
),
"ucs_per_viewport": DXFAttr(
71,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"ucs_icon": DXFAttr(74, default=0),
"ucs_origin": DXFAttr(110, xtype=XType.point3d, default=NULLVEC),
"ucs_x_axis": DXFAttr(
111,
xtype=XType.point3d,
default=X_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
"ucs_y_axis": DXFAttr(
112,
xtype=XType.point3d,
default=Y_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
# Handle of AcDbUCSTableRecord if UCS is a named UCS.
# If not present, then UCS is unnamed:
"ucs_handle": DXFAttr(345),
# Handle of AcDbUCSTableRecord of base UCS if UCS is orthographic (79 code
# is non-zero). If not present and 79 code is non-zero, then base UCS is
# taken to be WORLD:
"base_ucs_handle": DXFAttr(346, optional=True),
# UCS ortho type:
# 0 = not orthographic
# 1 = Top
# 2 = Bottom
# 3 = Front
# 4 = Back
# 5 = Left
# 6 = Right
"ucs_ortho_type": DXFAttr(
79,
default=0,
validator=validator.is_in_integer_range(0, 7),
fixer=RETURN_DEFAULT,
),
"elevation": DXFAttr(146, default=0),
# Shade plot mode:
# 0 = As Displayed
# 1 = Wireframe
# 2 = Hidden
# 3 = Rendered
"shade_plot_mode": DXFAttr(
170,
dxfversion="AC1018",
validator=validator.is_in_integer_range(0, 4),
fixer=RETURN_DEFAULT,
),
# Frequency of major grid lines compared to minor grid lines
"grid_frequency": DXFAttr(61, dxfversion="AC1021"),
"background_handle": DXFAttr(332, dxfversion="AC1021", optional=True),
"shade_plot_handle": DXFAttr(333, dxfversion="AC1021", optional=True),
"visual_style_handle": DXFAttr(348, dxfversion="AC1021", optional=True),
"default_lighting_flag": DXFAttr(
292, dxfversion="AC1021", default=1, optional=True
),
# Default lighting type:
# 0 = One distant light
# 1 = Two distant lights
"default_lighting_type": DXFAttr(
282,
default=0,
dxfversion="AC1021",
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"view_brightness": DXFAttr(141, dxfversion="AC1021"),
"view_contrast": DXFAttr(142, dxfversion="AC1021"),
# as AutoCAD Color Index
"ambient_light_color_1": DXFAttr(
63,
dxfversion="AC1021",
validator=validator.is_valid_aci_color,
),
# as True Color:
"ambient_light_color_2": DXFAttr(421, dxfversion="AC1021"),
# as True Color:
"ambient_light_color_3": DXFAttr(431, dxfversion="AC1021"),
"sun_handle": DXFAttr(361, dxfversion="AC1021", optional=True),
# The following attributes are mentioned in the DXF reference but may not really exist:
# "Soft pointer reference to viewport object (for layer VP property override)"
"ref_vp_object_1": DXFAttr(335, dxfversion="AC1021"), # soft-pointer
"ref_vp_object_2": DXFAttr(343, dxfversion="AC1021"), # hard-pointer
"ref_vp_object_3": DXFAttr(344, dxfversion="AC1021"), # hard-pointer
"ref_vp_object_4": DXFAttr(91, dxfversion="AC1021"), # this is not a pointer!
},
)
acdb_viewport_group_codes = group_code_mapping(acdb_viewport)
# Note:
# The ZOOM XP factor is calculated with the following formula:
# group_41 / group_45 (or pspace_height / mspace_height).
FROZEN_LAYER_GROUP_CODE = 331
@register_entity
class Viewport(DXFGraphic):
"""DXF VIEWPORT entity"""
DXFTYPE = "VIEWPORT"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_viewport)
# Notes to viewport_id:
# The id of the first viewport has to be 1, which is the definition of
# paper space. For the following viewports it seems only important, that
# the id is greater than 1.
def __init__(self) -> None:
super().__init__()
self._frozen_layers: list[str] = []
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
assert isinstance(entity, Viewport)
entity._frozen_layers = list(self._frozen_layers)
@property
def frozen_layers(self) -> list[str]:
"""Set/get frozen layers as list of layer names."""
return self._frozen_layers
@frozen_layers.setter
def frozen_layers(self, names: Iterable[str]):
self._frozen_layers = list(names)
def _layer_index(self, layer_name: str) -> int:
name_key = validator.make_table_key(layer_name)
for index, name in enumerate(self._frozen_layers):
if name_key == validator.make_table_key(name):
return index
return -1
def freeze(self, layer_name: str) -> None:
"""Freeze `layer_name` in this viewport."""
index = self._layer_index(layer_name)
if index == -1:
self._frozen_layers.append(layer_name)
def is_frozen(self, layer_name: str) -> bool:
"""Returns ``True`` if `layer_name` id frozen in this viewport."""
return self._layer_index(layer_name) != -1
def thaw(self, layer_name: str) -> None:
"""Thaw `layer_name` in this viewport."""
index = self._layer_index(layer_name)
if index != -1:
del self._frozen_layers[index]
@property
def is_visible(self) -> bool:
# VIEWPORT id == 1 or status == 1, this viewport defines the "active viewport"
# which is the area currently shown in the layout tab by the CAD
# application.
# BricsCAD set id to -1 if the viewport is off and 'status' (group
# code 68) is not present.
# status: -1= off-screen, 0= off, 1= "active viewport"
if self.dxf.hasattr("status"):
return self.dxf.status > 0
return self.dxf.id > 1
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.fast_load_dxfattribs(
dxf, acdb_viewport_group_codes, subclass=2, log=False
)
if processor.r12:
self.load_xdata_into_dxf_namespace()
else:
if len(tags):
tags = self.load_frozen_layer_handles(tags)
if len(tags):
processor.log_unprocessed_tags(tags, subclass=acdb_viewport.name)
return dxf
def post_load_hook(self, doc: Drawing):
super().post_load_hook(doc)
bag: list[str] = []
db = doc.entitydb
for handle in self._frozen_layers:
try:
bag.append(db[handle].dxf.name)
except KeyError: # ignore non-existing layers
pass
self._frozen_layers = bag
def load_frozen_layer_handles(self, tags: Tags) -> Tags:
unprocessed_tags = Tags()
for tag in tags:
if tag.code == FROZEN_LAYER_GROUP_CODE:
self._frozen_layers.append(tag.value)
else:
unprocessed_tags.append(tag)
return unprocessed_tags
def load_xdata_into_dxf_namespace(self) -> None:
try:
tags = [v for c, v in self.xdata.get_xlist("ACAD", "MVIEW")] # type: ignore
except DXFValueError:
return
tags = tags[3:-2]
dxf = self.dxf
flags = 0
flags = set_flag_state(flags, const.VSF_FAST_ZOOM, bool(tags[11]))
flags = set_flag_state(flags, const.VSF_SNAP_MODE, bool(tags[13]))
flags = set_flag_state(flags, const.VSF_GRID_MODE, bool(tags[14]))
flags = set_flag_state(flags, const.VSF_ISOMETRIC_SNAP_STYLE, bool(tags[15]))
flags = set_flag_state(flags, const.VSF_HIDE_PLOT_MODE, bool(tags[24]))
try:
dxf.view_target_point = tags[0]
dxf.view_direction_vector = tags[1]
dxf.view_twist_angle = tags[2]
dxf.view_height = tags[3]
dxf.view_center_point = tags[4], tags[5]
dxf.perspective_lens_length = tags[6]
dxf.front_clip_plane_z_value = tags[7]
dxf.back_clip_plane_z_value = tags[8]
dxf.render_mode = tags[9] # view_mode == render_mode ?
dxf.circle_zoom = tags[10]
# fast zoom flag : tag[11]
dxf.ucs_icon = tags[12]
# snap mode flag = tags[13]
# grid mode flag = tags[14]
# isometric snap style = tags[15]
# dxf.snap_isopair = tags[16] ???
dxf.snap_angle = tags[17]
dxf.snap_base_point = tags[18], tags[19]
dxf.snap_spacing = tags[20], tags[21]
dxf.grid_spacing = tags[22], tags[23]
# hide plot flag = tags[24]
except IndexError: # internal exception
raise DXFStructureError("Invalid viewport entity - missing data")
dxf.flags = flags
self._frozen_layers = tags[26:]
self.xdata.discard("ACAD") # type: ignore
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
if tagwriter.dxfversion == DXF12:
self.export_acdb_viewport_r12(tagwriter)
else:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_viewport.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"center",
"width",
"height",
"status",
"id",
"view_center_point",
"snap_base_point",
"snap_spacing",
"grid_spacing",
"view_direction_vector",
"view_target_point",
"perspective_lens_length",
"front_clip_plane_z_value",
"back_clip_plane_z_value",
"view_height",
"snap_angle",
"view_twist_angle",
"circle_zoom",
],
)
if len(self.frozen_layers):
assert self.doc is not None, "valid DXF document required"
layers = self.doc.layers
for layer_name in self.frozen_layers:
try:
layer = layers.get(layer_name)
except DXFTableEntryError:
pass
else:
tagwriter.write_tag2(FROZEN_LAYER_GROUP_CODE, layer.dxf.handle)
self.dxf.export_dxf_attribs(
tagwriter,
[
"flags",
"clipping_boundary_handle",
"plot_style_name",
"render_mode",
"ucs_per_viewport",
"ucs_icon",
"ucs_origin",
"ucs_x_axis",
"ucs_y_axis",
"ucs_handle",
"base_ucs_handle",
"ucs_ortho_type",
"elevation",
"shade_plot_mode",
"grid_frequency",
"background_handle",
"shade_plot_handle",
"visual_style_handle",
"default_lighting_flag",
"default_lighting_type",
"view_brightness",
"view_contrast",
"ambient_light_color_1",
"ambient_light_color_2",
"ambient_light_color_3",
"sun_handle",
"ref_vp_object_1",
"ref_vp_object_2",
"ref_vp_object_3",
"ref_vp_object_4",
],
)
def export_acdb_viewport_r12(self, tagwriter: AbstractTagWriter):
self.dxf.export_dxf_attribs(
tagwriter,
[
"center",
"width",
"height",
"status",
"id",
],
)
tagwriter.write_tags(self.dxftags())
def dxftags(self) -> Tags:
def flag(flag):
return 1 if self.dxf.flags & flag else 0
dxf = self.dxf
tags = [
DXFTag(1001, "ACAD"),
DXFTag(1000, "MVIEW"),
DXFTag(1002, "{"),
DXFTag(1070, 16), # extended data version, always 16 for R11/12
DXFVertex(1010, dxf.view_target_point),
DXFVertex(1010, dxf.view_direction_vector),
DXFTag(1040, dxf.view_twist_angle),
DXFTag(1040, dxf.view_height),
DXFTag(1040, dxf.view_center_point[0]),
DXFTag(
1040,
dxf.view_center_point[1],
),
DXFTag(1040, dxf.perspective_lens_length),
DXFTag(1040, dxf.front_clip_plane_z_value),
DXFTag(1040, dxf.back_clip_plane_z_value),
DXFTag(1070, dxf.render_mode),
DXFTag(1070, dxf.circle_zoom),
DXFTag(1070, flag(const.VSF_FAST_ZOOM)),
DXFTag(1070, dxf.ucs_icon),
DXFTag(1070, flag(const.VSF_SNAP_MODE)),
DXFTag(1070, flag(const.VSF_GRID_MODE)),
DXFTag(1070, flag(const.VSF_ISOMETRIC_SNAP_STYLE)),
DXFTag(1070, 0), # snap isopair ???
DXFTag(1040, dxf.snap_angle),
DXFTag(1040, dxf.snap_base_point[0]),
DXFTag(1040, dxf.snap_base_point[1]),
DXFTag(1040, dxf.snap_spacing[0]),
DXFTag(1040, dxf.snap_spacing[1]),
DXFTag(1040, dxf.grid_spacing[0]),
DXFTag(1040, dxf.grid_spacing[1]),
DXFTag(1070, flag(const.VSF_HIDE_PLOT_MODE)),
DXFTag(1002, "{"), # start frozen layer list
]
tags.extend(DXFTag(1003, layer_name) for layer_name in self.frozen_layers)
tags.extend(
[
DXFTag(1002, "}"), # end of frozen layer list
DXFTag(1002, "}"), # MVIEW
]
)
return Tags(tags)
def register_resources(self, registry: xref.Registry) -> None:
assert self.doc is not None
super().register_resources(registry)
# The clipping path entity should not be added here!
registry.add_handle(self.dxf.get("ucs_handle"))
registry.add_handle(self.dxf.get("base_ucs_handle"))
registry.add_handle(self.dxf.get("visual_style_handle"))
registry.add_handle(self.dxf.get("background_handle"))
registry.add_handle(self.dxf.get("shade_plot_handle"))
registry.add_handle(self.dxf.get("sun_handle"))
def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None:
assert isinstance(clone, Viewport)
super().map_resources(clone, mapping)
mapping.map_existing_handle(
self, clone, "clipping_boundary_handle", optional=True
)
mapping.map_existing_handle(self, clone, "ucs_handle", optional=True)
mapping.map_existing_handle(self, clone, "base_ucs_handle", optional=True)
mapping.map_existing_handle(self, clone, "visual_style_handle", optional=True)
mapping.map_existing_handle(self, clone, "sun_handle", optional=True)
# VIEWPORT entity is hard owner of the SUN object
clone.take_sun_ownership()
clone.frozen_layers = [mapping.get_layer(name) for name in self.frozen_layers]
# I have no information to what entities the background- and the shade_plot
# handles are pointing to and I don't have any examples for that!
mapping.map_existing_handle(self, clone, "background_handle", optional=True)
mapping.map_existing_handle(self, clone, "shade_plot_handle", optional=True)
# No information if these attributes really exist or any examples where these
# attributes are used. BricsCAD does not create these attributes when using
# viewport layer overrides:
for num in range(1, 5):
clone.dxf.discard(f"ref_vp_object_{num}")
def take_sun_ownership(self) -> None:
assert self.doc is not None
sun = self.doc.entitydb.get(self.dxf.get("sun_handle"))
if sun:
sun.dxf.owner = self.dxf.handle
def rename_frozen_layer(self, old_name: str, new_name: str) -> None:
assert self.doc is not None, "valid DXF document required"
key = self.doc.layers.key
old_key = key(old_name)
self.frozen_layers = [
(name if key(name) != old_key else new_name) for name in self.frozen_layers
]
def clipping_rect_corners(self) -> list[Vec2]:
"""Returns the default rectangular clipping path as list of
vertices. Use function :func:`ezdxf.path.make_path` to get also
non-rectangular shaped clipping paths if defined.
"""
center = self.dxf.center
cx = center.x
cy = center.y
width2 = self.dxf.width / 2
height2 = self.dxf.height / 2
return [
Vec2(cx - width2, cy - height2),
Vec2(cx + width2, cy - height2),
Vec2(cx + width2, cy + height2),
Vec2(cx - width2, cy + height2),
]
def clipping_rect(self) -> tuple[Vec2, Vec2]:
"""Returns the lower left and the upper right corner of the clipping
rectangle in paperspace coordinates.
"""
corners = self.clipping_rect_corners()
return corners[0], corners[2]
@property
def has_extended_clipping_path(self) -> bool:
"""Returns ``True`` if a non-rectangular clipping path is defined."""
_flag = self.dxf.flags & const.VSF_NON_RECTANGULAR_CLIPPING
if _flag:
handle = self.dxf.clipping_boundary_handle
return handle != "0"
return False
def get_scale(self) -> float:
"""Returns the scaling factor from modelspace to viewport."""
msp_height = self.dxf.view_height
if abs(msp_height) < 1e-12:
return 0.0
vp_height = self.dxf.height
return vp_height / msp_height
@property
def is_top_view(self) -> bool:
"""Returns ``True`` if the viewport is a top view."""
view_direction: Vec3 = self.dxf.view_direction_vector
return view_direction.is_null or view_direction.isclose(Z_AXIS)
def get_view_center_point(self) -> Vec3:
# TODO: Is there a flag or attribute that determines which of these points is
# the center point?
center_point = Vec3(self.dxf.view_center_point)
if center_point.is_null:
center_point = Vec3(self.dxf.view_target_point)
return center_point
def get_transformation_matrix(self) -> Matrix44:
"""Returns the transformation matrix from modelspace to paperspace coordinates."""
# supports only top-view viewports!
scale = self.get_scale()
rotation_angle: float = self.dxf.view_twist_angle
msp_center_point: Vec3 = self.get_view_center_point()
offset: Vec3 = self.dxf.center - (msp_center_point * scale)
m = Matrix44.scale(scale)
if rotation_angle:
m @= Matrix44.z_rotate(math.radians(rotation_angle))
return m @ Matrix44.translate(offset.x, offset.y, 0)
def get_aspect_ratio(self) -> float:
"""Returns the aspect ratio of the viewport, return 0.0 if width or
height is zero.
"""
try:
return self.dxf.width / self.dxf.height
except ZeroDivisionError:
return 0.0
def get_modelspace_limits(self) -> tuple[float, float, float, float]:
"""Returns the limits of the modelspace to view in drawing units
as tuple (min_x, min_y, max_x, max_y).
"""
msp_center_point: Vec3 = self.get_view_center_point()
msp_height: float = self.dxf.view_height
rotation_angle: float = self.dxf.view_twist_angle
ratio = self.get_aspect_ratio()
if ratio == 0.0:
raise ValueError("invalid viewport parameters width or height")
w2 = msp_height * ratio * 0.5
h2 = msp_height * 0.5
if rotation_angle:
frame = Vec2.list(((-w2, -h2), (w2, -h2), (w2, h2), (-w2, h2)))
angle = math.radians(rotation_angle)
bbox = BoundingBox2d(v.rotate(angle) + msp_center_point for v in frame)
return bbox.extmin.x, bbox.extmin.y, bbox.extmax.x, bbox.extmax.y
else:
mx, my, _ = msp_center_point
return mx - w2, my - h2, mx + w2, my + h2
@@ -0,0 +1,235 @@
# Copyright (c) 2019-2023, Manfred Moitzi
# License: MIT-License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing_extensions import Self
import copy
from ezdxf.lldxf import validator
from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2000, DXFStructureError
from ezdxf.lldxf.attributes import (
DXFAttributes,
DefSubclass,
DXFAttr,
group_code_mapping,
)
from ezdxf.lldxf.tags import Tags
from .dxfentity import base_class, SubclassProcessor
from .dxfobj import DXFObject
from .factory import register_entity
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace, DXFEntity
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["VisualStyle"]
acdb_visualstyle = DefSubclass(
"AcDbVisualStyle",
{
"description": DXFAttr(2),
# Style type:
# 0 = Flat
# 1 = FlatWithEdges
# 2 = Gouraud
# 3 = GouraudWithEdges
# 4 = 2dWireframe
# 5 = Wireframe
# 6 = Hidden
# 7 = Basic
# 8 = Realistic
# 9 = Conceptual
# 10 = Modeling
# 11 = Dim
# 12 = Brighten
# 13 = Thicken
# 14 = Linepattern
# 15 = Facepattern
# 16 = ColorChange
# 20 = JitterOff
# 21 = OverhangOff
# 22 = EdgeColorOff
# 23 = Shades of Gray
# 24 = Sketchy
# 25 = X-Ray
# 26 = Shaded with edges
# 27 = Shaded
"style_type": DXFAttr(
70, validator=validator.is_in_integer_range(0, 28)
),
# Face lighting type:
# 0 = Invisible
# 1 = Visible
# 2 = Phong
# 3 = Gooch
"face_lighting_model": DXFAttr(
71, validator=validator.is_in_integer_range(0, 4)
),
# Face lighting quality:
# 0 = No lighting
# 1 = Per face lighting
# 2 = Per vertex lighting
# 3 = Unknown
"face_lighting_quality": DXFAttr(
72, validator=validator.is_in_integer_range(0, 4)
),
# Face color mode:
# 0 = No color
# 1 = Object color
# 2 = Background color
# 3 = Custom color
# 4 = Mono color
# 5 = Tinted
# 6 = Desaturated
"face_color_mode": DXFAttr(
73, validator=validator.is_in_integer_range(0, 6)
),
# Face modifiers:
# 0 = No modifiers
# 1 = Opacity
# 2 = Specular
# 3 = Unknown
"face_modifiers": DXFAttr(
90, validator=validator.is_in_integer_range(0, 4)
),
"face_opacity_level": DXFAttr(40),
"face_specular_level": DXFAttr(41),
"color1": DXFAttr(62),
"color2": DXFAttr(63),
"face_style_mono_color": DXFAttr(421),
# Edge style model:
# 0 = No edges
# 1 = Isolines
# 2 = Facet edges
"edge_style_model": DXFAttr(
74, validator=validator.is_in_integer_range(0, 3)
),
"edge_style": DXFAttr(91),
"edge_intersection_color": DXFAttr(64),
"edge_obscured_color": DXFAttr(65),
"edge_obscured_linetype": DXFAttr(75),
"edge_intersection_linetype": DXFAttr(175),
"edge_crease_angle": DXFAttr(42),
"edge_modifiers": DXFAttr(92),
"edge_color": DXFAttr(66),
"edge_opacity_level": DXFAttr(43),
"edge_width": DXFAttr(76),
"edge_overhang": DXFAttr(77),
"edge_jitter": DXFAttr(78),
"edge_silhouette_color": DXFAttr(67),
"edge_silhouette_width": DXFAttr(79),
"edge_halo_gap": DXFAttr(170),
"edge_isoline_count": DXFAttr(171),
"edge_hide_precision": DXFAttr(290), # flag
"edge_style_apply": DXFAttr(174), # flag
"style_display_settings": DXFAttr(93),
"brightness": DXFAttr(44),
"shadow_type": DXFAttr(173),
"unknown1": DXFAttr(177), # required if xdata is present?
"internal_use_only_flag": DXFAttr(
291
), # visual style only use internal
# Xdata must follow tag 291 (AutoCAD -> 'Xdata wasn't read' error)
# 70: Xdata count (count of tag groups)
# any code, value: multiple tags build a tag group
# 176, 1: end of group marker
# e.g. (291, 0) (70, 2) (62, 7) (420, 16777215) (176, 1) (90, 1) (176, 1)
},
)
acdb_visualstyle_group_codes = group_code_mapping(acdb_visualstyle)
# undocumented Xdata in DXF R2018
@register_entity
class VisualStyle(DXFObject):
"""DXF VISUALSTYLE entity"""
DXFTYPE = "VISUALSTYLE"
DXFATTRIBS = DXFAttributes(base_class, acdb_visualstyle)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000 # official supported in R2007
def __init__(self):
super().__init__()
self.acad_xdata = None # to preserve AutoCAD xdata
def copy_data(self, entity: Self, copy_strategy=default_copy) -> None:
"""Copy acad internal data."""
assert isinstance(entity, VisualStyle)
entity.acad_xdata = copy.deepcopy(self.acad_xdata)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
tags = processor.subclass_by_index(1)
if tags:
self.acad_xdata = self.store_acad_xdata(tags)
processor.fast_load_dxfattribs(
dxf, acdb_visualstyle_group_codes, subclass=tags
)
else:
raise DXFStructureError(
f"missing 'AcDbVisualStyle' subclass in VISUALSTYLE(#{dxf.handle})"
)
return dxf
@staticmethod
def store_acad_xdata(tags: Tags):
try:
index = tags.tag_index(291)
except IndexError:
return None
else: # store tags after 291
index += 1
xdata = tags[index:]
del tags[index:] # remove xdata from subclass
return xdata
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_visualstyle.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"description",
"style_type",
"face_lighting_model",
"face_lighting_quality",
"face_color_mode",
"face_modifiers",
"face_opacity_level",
"face_specular_level",
"color1",
"color2",
"face_style_mono_color",
"edge_style_model",
"edge_style",
"edge_intersection_color",
"edge_obscured_color",
"edge_obscured_linetype",
"edge_intersection_linetype",
"edge_crease_angle",
"edge_modifiers",
"edge_color",
"edge_opacity_level",
"edge_width",
"edge_overhang",
"edge_jitter",
"edge_silhouette_color",
"edge_silhouette_width",
"edge_halo_gap",
"edge_isoline_count",
"edge_hide_precision",
"edge_style_apply",
"style_display_settings",
"brightness",
"shadow_type",
"unknown1",
"internal_use_only_flag",
],
)
if self.acad_xdata:
tagwriter.write_tags(self.acad_xdata)
@@ -0,0 +1,259 @@
# Copyright (c) 2019-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import logging
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import DXF12, SUBCLASS_MARKER, DXF2000, DXF2007
from ezdxf.lldxf.validator import is_valid_vport_name
from ezdxf.math import Vec2, NULLVEC, Z_AXIS
from ezdxf.entities.dxfentity import base_class, SubclassProcessor, DXFEntity
from ezdxf.entities.layer import acdb_symbol_table_record
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["VPort"]
logger = logging.getLogger("ezdxf")
acdb_vport = DefSubclass(
"AcDbViewportTableRecord",
{
"name": DXFAttr(2, validator=is_valid_vport_name),
"flags": DXFAttr(70, default=0),
"lower_left": DXFAttr(10, xtype=XType.point2d, default=Vec2(0, 0)),
"upper_right": DXFAttr(11, xtype=XType.point2d, default=Vec2(1, 1)),
"center": DXFAttr(12, xtype=XType.point2d, default=Vec2(0, 0)),
"snap_base": DXFAttr(13, xtype=XType.point2d, default=Vec2(0, 0)),
"snap_spacing": DXFAttr(
14, xtype=XType.point2d, default=Vec2(0.5, 0.5)
),
"grid_spacing": DXFAttr(
15, xtype=XType.point2d, default=Vec2(0.5, 0.5)
),
"direction": DXFAttr(
16,
xtype=XType.point3d,
default=Z_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
"target": DXFAttr(17, xtype=XType.point3d, default=NULLVEC),
# height: DXF reference error: listed as group code 45
"height": DXFAttr(40, default=1000),
"aspect_ratio": DXFAttr(41, default=1.34),
"focal_length": DXFAttr(42, default=50),
"front_clipping": DXFAttr(43, default=0),
"back_clipping": DXFAttr(44, default=0),
"snap_rotation": DXFAttr(50, default=0),
"view_twist": DXFAttr(51, default=0),
"view_mode": DXFAttr(71, default=0),
"circle_sides": DXFAttr(72, default=1000),
"fast_zoom": DXFAttr(73, default=1), # removed in R2007
# ucs_icon:
# bit 0: 0=hide, 1=show
# bit 1: 0=display in lower left corner, 1=display at origin
"ucs_icon": DXFAttr(74, default=3), # show at origin
"snap_on": DXFAttr(75, default=0), # removed in R2007
"grid_on": DXFAttr(76, default=0), # removed in R2007
"snap_style": DXFAttr(77, default=0), # removed in R2007
"snap_isopair": DXFAttr(78, default=0), # removed in R2007
# R2000: 331 or 441 (optional) - ignored by ezdxf
# Soft or hard-pointer ID/handle to frozen layer objects;
# repeats for each frozen layers
# 70: Bit flags and perspective mode
# CTB-File?
"plot_style_sheet": DXFAttr(1, dxfversion=DXF2007),
# Render mode:
# 0 = 2D Optimized (classic 2D)
# 1 = Wireframe
# 2 = Hidden line
# 3 = Flat shaded
# 4 = Gouraud shaded
# 5 = Flat shaded with wireframe
# 6 = Gouraud shaded with wireframe
# All rendering modes other than 2D Optimized engage the new 3D graphics
# pipeline. These values directly correspond to the SHADEMODE command and
# the AcDbAbstractViewTableRecord::RenderMode enum
"render_mode": DXFAttr(
281,
default=0,
dxfversion=DXF2000,
validator=validator.is_in_integer_range(0, 7),
fixer=RETURN_DEFAULT,
),
# Value of UCSVP for this viewport. If set to 1, then viewport stores its
# own UCS which will become the current UCS whenever the viewport is
# activated. If set to 0, UCS will not change when this viewport is
# activated
"ucs_vp": DXFAttr(
65,
dxfversion=DXF2000,
default=0,
validator=validator.is_integer_bool,
fixer=RETURN_DEFAULT,
),
"ucs_origin": DXFAttr(110, xtype=XType.point3d, dxfversion=DXF2000),
"ucs_xaxis": DXFAttr(
111,
xtype=XType.point3d,
dxfversion=DXF2000,
validator=validator.is_not_null_vector,
),
"ucs_yaxis": DXFAttr(
112,
xtype=XType.point3d,
dxfversion=DXF2000,
validator=validator.is_not_null_vector,
),
# Handle of AcDbUCSTableRecord if UCS is a named UCS. If not present,
# then UCS is unnamed:
"ucs_handle": DXFAttr(345, dxfversion=DXF2000),
# Handle of AcDbUCSTableRecord of base UCS if UCS is orthographic (79 code
# is non-zero). If not present and 79 code is non-zero, then base UCS is
# taken to be WORLD:
"base_ucs_handle": DXFAttr(346, dxfversion=DXF2000),
# UCS ortho type:
# 0 = UCS is not orthographic
# 1 = Top
# 2 = Bottom
# 3 = Front
# 4 = Back
# 5 = Left
# 6 = Right
"ucs_ortho_type": DXFAttr(
79,
dxfversion=DXF2000,
validator=validator.is_in_integer_range(0, 7),
),
"elevation": DXFAttr(146, dxfversion=DXF2000, default=0),
"unknown1": DXFAttr(60, dxfversion=DXF2000),
"shade_plot_setting": DXFAttr(170, dxfversion=DXF2007),
"major_grid_lines": DXFAttr(61, dxfversion=DXF2007),
# Handle to background object
"background_handle": DXFAttr(332, dxfversion=DXF2007, optional=True),
# Handle to shade plot object
"shade_plot_handle": DXFAttr(333, dxfversion=DXF2007, optional=True),
# Handle to visual style object
"visual_style_handle": DXFAttr(348, dxfversion=DXF2007, optional=True),
"default_lighting_on": DXFAttr(
292,
dxfversion=DXF2007,
validator=validator.is_integer_bool,
),
# Default lighting type:
# 0 = One distant light
# 1 = Two distant lights
"default_lighting_type": DXFAttr(
282,
dxfversion=DXF2007,
validator=validator.is_integer_bool,
),
"brightness": DXFAttr(141, dxfversion=DXF2000),
"contrast": DXFAttr(142, dxfversion=DXF2000),
"ambient_color_aci": DXFAttr(63, dxfversion=DXF2000, optional=True),
"ambient_true_color": DXFAttr(421, dxfversion=DXF2000, optional=True),
"ambient_color_name": DXFAttr(431, dxfversion=DXF2000, optional=True),
# Hard-pointer handle to sun object:
"sun_handle": DXFAttr(361, dxfversion=DXF2007, optional=True),
},
)
acdb_vport_group_codes = group_code_mapping(acdb_vport)
@register_entity
class VPort(DXFEntity):
"""DXF VIEW entity"""
DXFTYPE = "VPORT"
DXFATTRIBS = DXFAttributes(base_class, acdb_symbol_table_record, acdb_vport)
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_vport_group_codes, subclass=2
)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
super().export_entity(tagwriter)
dxfversion = tagwriter.dxfversion
if dxfversion > DXF12:
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_symbol_table_record.name)
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_vport.name)
self.dxf.export_dxf_attribs(
tagwriter,
[
"name",
"flags",
"lower_left",
"upper_right",
"center",
"snap_base",
"snap_spacing",
"grid_spacing",
"direction",
"target",
"height",
"aspect_ratio",
"focal_length",
"front_clipping",
"back_clipping",
"snap_rotation",
"view_twist",
"view_mode",
"circle_sides",
"fast_zoom",
"ucs_icon",
"snap_on",
"grid_on",
"snap_style",
"snap_isopair",
"plot_style_sheet",
"render_mode",
"ucs_vp",
"ucs_origin",
"ucs_xaxis",
"ucs_yaxis",
"ucs_handle",
"base_ucs_handle",
"ucs_ortho_type",
"elevation",
"unknown1",
"shade_plot_setting",
"major_grid_lines",
"background_handle",
"shade_plot_handle",
"visual_style_handle",
"default_lighting_on",
"default_lighting_type",
"brightness",
"contrast",
"ambient_color_aci",
"ambient_true_color",
"ambient_color_name",
],
)
def reset_wcs(self) -> None:
"""Reset coordinate system to the :ref:`WCS`."""
self.dxf.ucs_vp = 1
self.dxf.ucs_origin = (0, 0, 0)
self.dxf.ucs_xaxis = (1, 0, 0)
self.dxf.ucs_yaxis = (0, 1, 0)
self.dxf.ucs_ortho_type = 0
self.dxf.discard("ucs_handle")
self.dxf.discard("base_ucs_handle")
@@ -0,0 +1,531 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Iterable,
Any,
MutableSequence,
MutableMapping,
Iterator,
Union,
Optional,
)
from contextlib import contextmanager
import logging
from ezdxf.math import Vec3, Matrix44
from ezdxf.lldxf.types import dxftag, VALID_XDATA_GROUP_CODES, DXFTag
from ezdxf.lldxf.tags import Tags
from ezdxf.lldxf.const import XDATA_MARKER, DXFValueError, DXFTypeError
from ezdxf.lldxf.tags import (
xdata_list,
remove_named_list_from_xdata,
get_named_list_from_xdata,
NotFoundException,
)
from ezdxf.tools import take2
from ezdxf import options
from ezdxf.lldxf.repair import filter_invalid_xdata_group_codes
if TYPE_CHECKING:
from ezdxf.entities import DXFEntity
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["XData", "XDataUserList", "XDataUserDict"]
logger = logging.getLogger("ezdxf")
def has_valid_xdata_group_codes(tags: Tags) -> bool:
return all(tag.code in VALID_XDATA_GROUP_CODES for tag in tags)
class XData:
def __init__(self, xdata: Optional[Iterable[Tags]] = None):
self.data: dict[str, Tags] = dict()
if xdata is not None:
for data in xdata:
self._add(data)
@classmethod
def safe_init(cls, xdata: Iterable[Tags]):
return cls(
[Tags(filter_invalid_xdata_group_codes(tags)) for tags in xdata]
)
def __len__(self):
return len(self.data)
def __contains__(self, appid: str) -> bool:
"""Returns ``True`` if DXF tags for `appid` exist."""
return appid in self.data
def update_keys(self):
"""Update APPID keys. (internal API)"""
self.data = {tags[0].value: tags for tags in self.data.values()}
def _add(self, tags: Tags) -> None:
tags = Tags(tags)
if len(tags):
appid = tags[0].value
if appid in self.data:
logger.info(f"Duplicate XDATA appid {appid} in one entity")
if has_valid_xdata_group_codes(tags):
self.data[appid] = tags
else:
raise DXFValueError(f"found invalid XDATA group code in {tags}")
def add(
self, appid: str, tags: Iterable[Union[tuple[int, Any], DXFTag]]
) -> None:
"""Add a list of DXF tags for `appid`. The `tags` argument is an
iterable of (group code, value) tuples, where the group code has to be
an integer value. The mandatory XDATA marker (1001, appid) is added
automatically if front of the tags if missing.
Each entity can contain only one list of tags for each `appid`.
Adding a second list of tags for the same `appid` replaces the
existing list of tags.
The valid XDATA group codes are restricted to some specific values in
the range from 1000 to 1071, for more information see also the
internals about :ref:`xdata_internals`.
"""
data = Tags(dxftag(code, value) for code, value in tags)
if len(data) == 0 or data[0] != (XDATA_MARKER, appid):
data.insert(0, dxftag(XDATA_MARKER, appid))
self._add(data)
def get(self, appid: str) -> Tags:
"""Returns the DXF tags as :class:`~ezdxf.lldxf.tags.Tags` list
stored by `appid`.
Raises:
DXFValueError: no data for `appid` exist
"""
if appid in self.data:
return self.data[appid]
else:
raise DXFValueError(appid)
def discard(self, appid):
"""Delete DXF tags for `appid`. None existing appids are silently
ignored.
"""
if appid in self.data:
del self.data[appid]
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
for appid, tags in self.data.items():
if options.filter_invalid_xdata_group_codes:
tags = Tags(filter_invalid_xdata_group_codes(tags))
tagwriter.write_tags(tags)
def has_xlist(self, appid: str, name: str) -> bool:
"""Returns ``True`` if list `name` from XDATA `appid` exists.
Args:
appid: APPID
name: list name
"""
try:
self.get_xlist(appid, name)
except DXFValueError:
return False
else:
return True
def get_xlist(self, appid: str, name: str) -> list[tuple]:
"""Get list `name` from XDATA `appid`.
Args:
appid: APPID
name: list name
Returns: list of DXFTags including list name and curly braces '{' '}' tags
Raises:
DXFKeyError: XDATA `appid` does not exist
DXFValueError: list `name` does not exist
"""
xdata = self.get(appid)
try:
return get_named_list_from_xdata(name, xdata)
except NotFoundException:
raise DXFValueError(
f'No data list "{name}" not found for APPID "{appid}"'
)
def set_xlist(self, appid: str, name: str, tags: Iterable) -> None:
"""Create new list `name` of XDATA `appid` with `xdata_tags` and
replaces list `name` if already exists.
Args:
appid: APPID
name: list name
tags: list content as DXFTags or (code, value) tuples, list name and
curly braces '{' '}' tags will be added
"""
if appid not in self.data:
data = [(XDATA_MARKER, appid)]
data.extend(xdata_list(name, tags))
self.add(appid, data)
else:
self.replace_xlist(appid, name, tags)
def discard_xlist(self, appid: str, name: str) -> None:
"""Deletes list `name` from XDATA `appid`. Ignores silently if XDATA
`appid` or list `name` not exist.
Args:
appid: APPID
name: list name
"""
try:
xdata = self.get(appid)
except DXFValueError:
pass
else:
try:
tags = remove_named_list_from_xdata(name, xdata)
except NotFoundException:
pass
else:
self.add(appid, tags)
def replace_xlist(self, appid: str, name: str, tags: Iterable) -> None:
"""Replaces list `name` of existing XDATA `appid` by `tags`. Appends
new list if list `name` do not exist, but raises `DXFValueError` if
XDATA `appid` do not exist.
Low level interface, if not sure use `set_xdata_list()` instead.
Args:
appid: APPID
name: list name
tags: list content as DXFTags or (code, value) tuples, list name and
curly braces '{' '}' tags will be added
Raises:
DXFValueError: XDATA `appid` do not exist
"""
xdata = self.get(appid)
try:
data = remove_named_list_from_xdata(name, xdata)
except NotFoundException:
data = xdata
xlist = xdata_list(name, tags)
data.extend(xlist)
self.add(appid, data)
def transform(self, m: Matrix44) -> None:
"""Transform XDATA tags with group codes 1011, 1012, 1013, 1041 and
1042 inplace. For more information see :ref:`xdata_internals` Internals.
"""
transformed_data = dict()
for key, tags in self.data.items():
transformed_data[key] = Tags(transform_xdata_tags(tags, m))
self.data = transformed_data
def transform_xdata_tags(tags: Tags, m: Matrix44) -> Iterator[DXFTag]:
for tag in tags:
code, value = tag
if code == 1011:
# move, scale, rotate and mirror
yield dxftag(code, m.transform(Vec3(value)))
elif code == 1012:
# scale, rotate and mirror
yield dxftag(code, m.transform_direction(Vec3(value)))
elif code == 1013:
# rotate and mirror
vec = Vec3(value)
length = vec.magnitude
if length > 1e-12:
vec = m.transform_direction(vec).normalize(length)
yield dxftag(code, vec)
else:
yield tag
elif code == 1041 or code == 1042:
# scale distance and factor, works only for uniform scaling
vec = m.transform_direction(Vec3(value, 0, 0))
yield dxftag(code, vec.magnitude)
else:
yield tag
class XDataUserList(MutableSequence):
"""Manage named XDATA lists as a list-like object.
Stores just a few data types with fixed group codes:
1000 str
1010 Vec3
1040 float
1071 32bit int
"""
converter = {
1000: str,
1010: Vec3,
1040: float,
1071: int,
}
group_codes = {
str: 1000,
Vec3: 1010,
float: 1040,
int: 1071,
}
def __init__(
self, xdata: Optional[XData] = None, name="DefaultList", appid="EZDXF"
):
"""Setup a XDATA user list `name` for the given `appid`.
The data is stored in the given `xdata` object, or in a new created
:class:`XData` instance if ``None``.
Changes of the content has to be committed at the end to be stored in
the underlying `xdata` object.
Args:
xdata (XData): underlying :class:`XData` instance, if ``None`` a
new one will be created
name (str): name of the user list
appid (str): application specific AppID
"""
if xdata is None:
xdata = XData()
self.xdata = xdata
self._appid = str(appid)
self._name = str(name)
try:
data = xdata.get_xlist(self._appid, self._name)
except DXFValueError:
data = []
self._data: list = self._parse_list(data)
@classmethod
@contextmanager
def entity(
cls, entity: DXFEntity, name="DefaultList", appid="EZDXF"
) -> Iterator[XDataUserList]:
"""Context manager to manage a XDATA list `name` for a given DXF
`entity`. Appends the user list to the existing :class:`XData` instance
or creates new :class:`XData` instance.
Args:
entity (DXFEntity): target DXF entity for the XDATA
name (str): name of the user list
appid (str): application specific AppID
"""
xdata = entity.xdata
if xdata is None:
xdata = XData()
entity.xdata = xdata
xlist = cls(xdata, name, appid)
yield xlist
xlist.commit()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.commit()
def __str__(self):
"""Return str(self)."""
return str(self._data)
def insert(self, index: int, value) -> None:
self._data.insert(index, value)
def __getitem__(self, item):
"""Get self[item]."""
return self._data[item]
def __setitem__(self, item, value):
"""Set self[item] to value."""
self._data.__setitem__(item, value)
def __delitem__(self, item):
"""Delete self[item]."""
self._data.__delitem__(item)
def _parse_list(self, tags: Iterable[tuple]) -> list:
data = list(tags)
content = []
for code, value in data[2:-1]:
factory = self.converter.get(code)
if factory:
content.append(factory(value))
else:
raise DXFValueError(f"unsupported group code: {code}")
return content
def __len__(self) -> int:
"""Returns len(self)."""
return len(self._data)
def commit(self) -> None:
"""Store all changes to the underlying :class:`XData` instance.
This call is not required if using the :meth:`entity` context manager.
Raises:
DXFValueError: invalid chars ``"\\n"`` or ``"\\r"`` in a string
DXFTypeError: invalid data type
"""
data = []
for value in self._data:
if isinstance(value, str):
if len(value) > 255: # XDATA limit for group code 1000
raise DXFValueError("string too long, max. 255 characters")
if "\n" in value or "\r" in value:
raise DXFValueError(
"found invalid line break '\\n' or '\\r'"
)
code = self.group_codes.get(type(value))
if code:
data.append(dxftag(code, value))
else:
raise DXFTypeError(f"invalid type: {type(value)}")
self.xdata.set_xlist(self._appid, self._name, data)
class XDataUserDict(MutableMapping):
"""Manage named XDATA lists as a dict-like object.
Uses XDataUserList to store key, value pairs in XDATA.
This class does not create the required AppID table entry, only the
default AppID "EZDXF" exist by default.
Implements the :class:`MutableMapping` interface.
"""
def __init__(
self, xdata: Optional[XData] = None, name="DefaultDict", appid="EZDXF"
):
"""Setup a XDATA user dict `name` for the given `appid`.
The data is stored in the given `xdata` object, or in a new created
:class:`XData` instance if ``None``.
Changes of the content has to be committed at the end to be stored in
the underlying `xdata` object.
Args:
xdata (XData): underlying :class:`XData` instance, if ``None`` a
new one will be created
name (str): name of the user list
appid (str): application specific AppID
"""
self._xlist = XDataUserList(xdata, name, appid)
self._user_dict: dict[str, Any] = self._parse_xlist()
def _parse_xlist(self) -> dict:
if self._xlist:
return dict(take2(self._xlist))
else:
return dict()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.commit()
def __str__(self):
"""Return str(self)."""
return str(self._user_dict)
@classmethod
@contextmanager
def entity(
cls, entity: DXFEntity, name="DefaultDict", appid="EZDXF"
) -> Iterator[XDataUserDict]:
"""Context manager to manage a XDATA dict `name` for a given DXF
`entity`. Appends the user dict to the existing :class:`XData` instance
or creates new :class:`XData` instance.
Args:
entity (DXFEntity): target DXF entity for the XDATA
name (str): name of the user list
appid (str): application specific AppID
"""
xdata = entity.xdata
if xdata is None:
xdata = XData()
entity.xdata = xdata
xdict = cls(xdata, name, appid)
yield xdict
xdict.commit()
@property
def xdata(self):
return self._xlist.xdata
def __len__(self):
"""Returns len(self)."""
return len(self._user_dict)
def __getitem__(self, key):
"""Get self[key]."""
return self._user_dict[key]
def __setitem__(self, key, item):
"""Set self[key] to value, key has to be a string.
Raises:
DXFTypeError: key is not a string
"""
if not isinstance(key, str):
raise DXFTypeError("key is not a string")
self._user_dict[key] = item
def __delitem__(self, key):
"""Delete self[key]."""
del self._user_dict[key]
def __iter__(self):
"""Implement iter(self)."""
return iter(self._user_dict)
def discard(self, key):
"""Delete self[key], without raising a :class:`KeyError` if `key` does
not exist.
"""
try:
del self._user_dict[key]
except KeyError:
pass
def commit(self) -> None:
"""Store all changes to the underlying :class:`XData` instance.
This call is not required if using the :meth:`entity` context manager.
Raises:
DXFValueError: invalid chars ``"\\n"`` or ``"\\r"`` in a string
DXFTypeError: invalid data type
"""
xlist = self._xlist
xlist.clear()
for key, value in self._user_dict.items():
xlist.append(key)
xlist.append(value)
xlist.commit()
@@ -0,0 +1,241 @@
# Copyright (c) 2019-2023 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Union, Optional
from ezdxf.lldxf.tags import Tags
from ezdxf.lldxf.const import DXFStructureError
from ezdxf.lldxf.const import (
ACAD_XDICTIONARY,
XDICT_HANDLE_CODE,
APP_DATA_MARKER,
)
from .copy import default_copy
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.entities import (
Dictionary,
DXFEntity,
DXFObject,
Placeholder,
DictionaryVar,
XRecord,
)
__all__ = ["ExtensionDict"]
# Example for table head and -entries with extension dicts:
# AutodeskSamples\lineweights.dxf
class ExtensionDict:
"""Stores extended data of entities in app data 'ACAD_XDICTIONARY', app
data contains just one entry to a hard-owned DICTIONARY objects, which is
not shared with other entities, each entity copy has its own extension
dictionary and the extension dictionary is destroyed when the owner entity
is deleted from database.
"""
__slots__ = ("_xdict",)
def __init__(self, xdict: Union[str, Dictionary]):
# 1st loading stage: xdict as string -> handle to dict
# 2nd loading stage: xdict as DXF Dictionary
self._xdict = xdict
@property
def dictionary(self) -> Dictionary:
"""Returns the underlying :class:`~ezdxf.entities.Dictionary` object."""
xdict = self._xdict
assert xdict is not None, "destroyed extension dictionary"
assert not isinstance(xdict, str), f"dictionary handle #{xdict} not resolved"
return xdict
@property
def handle(self) -> str:
"""Returns the handle of the underlying :class:`~ezdxf.entities.Dictionary`
object.
"""
return self.dictionary.dxf.handle
def __getitem__(self, key: str):
"""Get self[key]."""
return self.dictionary[key]
def __setitem__(self, key: str, value):
"""Set self[key] to value.
Only DXF objects stored in the OBJECTS section are allowed as content
of the extension dictionary. DXF entities stored in layouts are not
allowed.
Raises:
DXFTypeError: invalid DXF type
"""
self.dictionary[key] = value
def __delitem__(self, key: str):
"""Delete self[key], destroys referenced entity."""
del self.dictionary[key]
def __contains__(self, key: str):
"""Return `key` in self."""
return key in self.dictionary
def __len__(self):
"""Returns count of extension dictionary entries."""
return len(self.dictionary)
def keys(self):
"""Returns a :class:`KeysView` of all extension dictionary keys."""
return self.dictionary.keys()
def items(self):
"""Returns an :class:`ItemsView` for all extension dictionary entries as
(key, entity) pairs. An entity can be a handle string if the entity
does not exist.
"""
return self.dictionary.items()
def get(self, key: str, default=None) -> Optional[DXFEntity]:
"""Return extension dictionary entry `key`."""
return self.dictionary.get(key, default)
def discard(self, key: str) -> None:
"""Discard extension dictionary entry `key`."""
return self.dictionary.discard(key)
@classmethod
def new(cls, owner_handle: str, doc: Drawing):
xdict = doc.objects.add_dictionary(
owner=owner_handle,
# All data in the extension dictionary belongs only to the owner
hard_owned=True,
)
return cls(xdict)
def copy(self, copy_strategy=default_copy) -> ExtensionDict:
"""Deep copy of the extension dictionary all entries are virtual
entities.
"""
new_xdict = copy_strategy.copy(self.dictionary)
return ExtensionDict(new_xdict)
@property
def is_alive(self):
"""Returns ``True`` if the underlying :class:`~ezdxf.entities.Dictionary`
object is not deleted.
"""
# Can not check if _xdict (as handle or Dictionary) really exist:
return self._xdict is not None
@property
def has_valid_dictionary(self):
"""Returns ``True`` if the underlying :class:`~ezdxf.entities.Dictionary`
really exist and is valid.
"""
xdict = self._xdict
if xdict is None or isinstance(xdict, str):
return False
return xdict.is_alive
def update_owner(self, handle: str) -> None:
"""Update owner tag of underlying :class:`~ezdxf.entities.Dictionary`
object.
Internal API.
"""
assert self.is_alive, "destroyed extension dictionary"
self.dictionary.dxf.owner = handle
@classmethod
def from_tags(cls, tags: Tags):
assert tags is not None
# Expected DXF structure:
# [(102, '{ACAD_XDICTIONARY', (360, handle), (102, '}')]
if len(tags) != 3 or tags[1].code != XDICT_HANDLE_CODE:
raise DXFStructureError("ACAD_XDICTIONARY error.")
return cls(tags[1].value)
def load_resources(self, doc: Drawing) -> None:
handle = self._xdict
assert isinstance(handle, str)
self._xdict = doc.entitydb.get(handle) # type: ignore
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
assert self._xdict is not None
xdict = self._xdict
handle = xdict if isinstance(xdict, str) else xdict.dxf.handle
tagwriter.write_tag2(APP_DATA_MARKER, ACAD_XDICTIONARY)
tagwriter.write_tag2(XDICT_HANDLE_CODE, handle)
tagwriter.write_tag2(APP_DATA_MARKER, "}")
def destroy(self):
"""Destroy the underlying :class:`~ezdxf.entities.Dictionary` object."""
if self.has_valid_dictionary:
self._xdict.destroy()
self._xdict = None
def add_dictionary(self, name: str, hard_owned: bool = True) -> Dictionary:
"""Create a new :class:`~ezdxf.entities.Dictionary` object as
extension dictionary entry `name`.
"""
dictionary = self.dictionary
doc = dictionary.doc
assert doc is not None, "valid DXF document required"
new_dict = doc.objects.add_dictionary(
owner=dictionary.dxf.handle,
hard_owned=hard_owned,
)
dictionary[name] = new_dict
return new_dict
def add_xrecord(self, name: str) -> XRecord:
"""Create a new :class:`~ezdxf.entities.XRecord` object as
extension dictionary entry `name`.
"""
dictionary = self.dictionary
doc = dictionary.doc
assert doc is not None, "valid DXF document required"
xrecord = doc.objects.add_xrecord(dictionary.dxf.handle)
dictionary[name] = xrecord
return xrecord
def add_dictionary_var(self, name: str, value: str) -> DictionaryVar:
"""Create a new :class:`~ezdxf.entities.DictionaryVar` object as
extension dictionary entry `name`.
"""
dictionary = self.dictionary
doc = dictionary.doc
assert doc is not None, "valid DXF document required"
dict_var = doc.objects.add_dictionary_var(dictionary.dxf.handle, value)
dictionary[name] = dict_var
return dict_var
def add_placeholder(self, name: str) -> Placeholder:
"""Create a new :class:`~ezdxf.entities.Placeholder` object as
extension dictionary entry `name`.
"""
dictionary = self.dictionary
doc = dictionary.doc
assert doc is not None, "valid DXF document required"
placeholder = doc.objects.add_placeholder(dictionary.dxf.handle)
dictionary[name] = placeholder
return placeholder
def link_dxf_object(self, name: str, obj: DXFObject) -> None:
"""Link `obj` to the extension dictionary as entry `name`.
Linked objects are owned by the extensions dictionary and therefore
cannot be a graphical entity, which have to be owned by a
:class:`~ezdxf.layouts.BaseLayout`.
Raises:
DXFTypeError: `obj` has invalid DXF type
"""
self.dictionary.link_dxf_object(name, obj)
@@ -0,0 +1,95 @@
# Copyright (c) 2019-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from ezdxf.lldxf import validator
from ezdxf.lldxf.attributes import (
DXFAttr,
DXFAttributes,
DefSubclass,
XType,
RETURN_DEFAULT,
group_code_mapping,
)
from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2000
from ezdxf.math import Vec3, Matrix44, NULLVEC, Z_AXIS
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity
from .factory import register_entity
if TYPE_CHECKING:
from ezdxf.entities import DXFNamespace
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = ["Ray", "XLine"]
acdb_xline = DefSubclass(
"AcDbXline",
{
"start": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
"unit_vector": DXFAttr(
11,
xtype=XType.point3d,
default=Z_AXIS,
validator=validator.is_not_null_vector,
fixer=RETURN_DEFAULT,
),
},
)
acdb_xline_group_codes = group_code_mapping(acdb_xline)
@register_entity
class XLine(DXFGraphic):
"""DXF XLINE entity"""
DXFTYPE = "XLINE"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_xline)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
XLINE_SUBCLASS = "AcDbXline"
def load_dxf_attribs(
self, processor: Optional[SubclassProcessor] = None
) -> DXFNamespace:
dxf = super().load_dxf_attribs(processor)
if processor:
processor.fast_load_dxfattribs(
dxf, acdb_xline_group_codes, subclass=2, recover=True
)
return dxf
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
"""Export entity specific data as DXF tags."""
super().export_entity(tagwriter)
tagwriter.write_tag2(SUBCLASS_MARKER, self.XLINE_SUBCLASS)
self.dxf.export_dxf_attribs(tagwriter, ["start", "unit_vector"])
def transform(self, m: Matrix44) -> XLine:
"""Transform the XLINE/RAY entity by transformation matrix `m` inplace."""
self.dxf.start = m.transform(self.dxf.start)
self.dxf.unit_vector = m.transform_direction(
self.dxf.unit_vector
).normalize()
self.post_transform(m)
return self
def translate(self, dx: float, dy: float, dz: float) -> XLine:
"""Optimized XLINE/RAY translation about `dx` in x-axis, `dy` in
y-axis and `dz` in z-axis.
"""
self.dxf.start = Vec3(dx, dy, dz) + self.dxf.start
# Avoid Matrix44 instantiation if not required:
if self.is_post_transform_required:
self.post_transform(Matrix44.translate(dx, dy, dz))
return self
@register_entity
class Ray(XLine):
"""DXF Ray entity"""
DXFTYPE = "RAY"
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_xline)
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
XLINE_SUBCLASS = "AcDbRay"