1# Copyright (c) 2021, Manfred Moitzi 2# License: MIT License 3from typing import Iterable, Optional, cast, TYPE_CHECKING, List 4import abc 5import math 6from ezdxf.entities import DXFEntity 7from ezdxf.lldxf import const 8from ezdxf.math import Vec3, UCS, Z_AXIS, X_AXIS 9from ezdxf.path import Path, make_path, from_hatch, from_vertices 10from ezdxf.render import MeshBuilder, MeshVertexMerger, TraceBuilder 11 12from ezdxf.proxygraphic import ProxyGraphic 13from ezdxf.tools.text import ( 14 TextLine, unified_alignment, plain_text, text_wrap, 15) 16from ezdxf.tools import fonts 17 18if TYPE_CHECKING: 19 from ezdxf.eztypes import LWPolyline, Polyline, MText, Hatch, Insert 20 21__all__ = [ 22 "make_primitive", "recursive_decompose", "to_primitives", "to_vertices", 23 "to_control_vertices", "to_paths", "to_meshes" 24] 25 26 27class Primitive(abc.ABC): 28 """ It is not efficient to create the Path() or MeshBuilder() representation 29 by default. For some entities the it's just not needed (LINE, POINT) and for 30 others the builtin flattening() method is more efficient or accurate than 31 using a Path() proxy object. (ARC, CIRCLE, ELLIPSE, SPLINE). 32 33 The `max_flattening_distance` defines the max distance between the 34 approximation line and the original curve. Use argument 35 `max_flattening_distance` to override the default value, or set the value 36 by direct attribute access. 37 38 """ 39 max_flattening_distance: float = 0.01 40 41 def __init__(self, entity: DXFEntity, max_flattening_distance=None): 42 self.entity: DXFEntity = entity 43 # Path representation for linear entities: 44 self._path: Optional[Path] = None 45 # MeshBuilder representation for mesh based entities: 46 # PolygonMesh, PolyFaceMesh, Mesh 47 self._mesh: Optional[MeshBuilder] = None 48 if max_flattening_distance: 49 self.max_flattening_distance = max_flattening_distance 50 51 @property 52 def is_empty(self) -> bool: 53 """ Returns `True` if represents an empty primitive which do not 54 yield any vertices. 55 56 """ 57 if self._mesh: 58 return len(self._mesh.vertices) == 0 59 return self.path is None # on demand calculations! 60 61 @property 62 def path(self) -> Optional[Path]: 63 """ :class:`~ezdxf.path.Path` representation or ``None``, 64 idiom to check if is a path representation (could be empty):: 65 66 if primitive.path is not None: 67 process(primitive.path) 68 69 """ 70 return None 71 72 @property 73 def mesh(self) -> Optional[MeshBuilder]: 74 """ :class:`~ezdxf.render.mesh.MeshBuilder` representation or ``None``, 75 idiom to check if is a mesh representation (could be empty):: 76 77 if primitive.mesh is not None: 78 process(primitive.mesh) 79 80 """ 81 return None 82 83 @abc.abstractmethod 84 def vertices(self) -> Iterable[Vec3]: 85 """ Yields all vertices of the path/mesh representation as 86 :class:`~ezdxf.math.Vec3` objects. 87 88 """ 89 pass 90 91 92class EmptyPrimitive(Primitive): 93 @property 94 def is_empty(self) -> bool: 95 return True 96 97 def vertices(self) -> Iterable[Vec3]: 98 return [] 99 100 101class ConvertedPrimitive(Primitive): 102 """ Base class for all DXF entities which store the path/mesh representation 103 at instantiation. 104 105 """ 106 107 def __init__(self, entity: DXFEntity): 108 super().__init__(entity) 109 self._convert_entity() 110 111 @abc.abstractmethod 112 def _convert_entity(self): 113 """ This method creates the path/mesh representation. """ 114 pass 115 116 @property 117 def path(self) -> Optional[Path]: 118 return self._path 119 120 @property 121 def mesh(self) -> Optional[MeshBuilder]: 122 return self._mesh 123 124 def vertices(self) -> Iterable[Vec3]: 125 if self.path: 126 yield from self._path.flattening(self.max_flattening_distance) 127 elif self.mesh: 128 yield from self._mesh.vertices 129 130 131class CurvePrimitive(Primitive): 132 @property 133 def path(self) -> Optional[Path]: 134 """ Create path representation on demand. """ 135 if self._path is None: 136 self._path = make_path(self.entity) 137 return self._path 138 139 def vertices(self) -> Iterable[Vec3]: 140 # Not faster but more precise, because cubic bezier curves do not 141 # perfectly represent elliptic arcs (CIRCLE, ARC, ELLIPSE). 142 # SPLINE: cubic bezier curves do not perfectly represent splines with 143 # degree != 3. 144 yield from self.entity.flattening(self.max_flattening_distance) 145 146 147class LinePrimitive(Primitive): 148 @property 149 def path(self) -> Optional[Path]: 150 """ Create path representation on demand. """ 151 if self._path is None: 152 self._path = make_path(self.entity) 153 return self._path 154 155 def vertices(self) -> Iterable[Vec3]: 156 e = self.entity 157 yield e.dxf.start 158 yield e.dxf.end 159 160 161class LwPolylinePrimitive(ConvertedPrimitive): 162 def _convert_entity(self): 163 e: 'LWPolyline' = cast('LWPolyline', self.entity) 164 if e.has_width: # use a mesh representation: 165 tb = TraceBuilder.from_polyline(e) 166 mb = MeshVertexMerger() # merges coincident vertices 167 for face in tb.faces(): 168 mb.add_face(Vec3.generate(face)) 169 self._mesh = MeshBuilder.from_builder(mb) 170 else: # use a path representation to support bulges! 171 self._path = make_path(e) 172 173 174class PointPrimitive(Primitive): 175 @property 176 def path(self) -> Optional[Path]: 177 """ Create path representation on demand. 178 179 :class:`Path` can not represent a point, a :class:`Path` with only a 180 start point yields not vertices! 181 182 """ 183 if self._path is None: 184 self._path = Path(self.entity.dxf.location) 185 return self._path 186 187 def vertices(self) -> Iterable[Vec3]: 188 yield self.entity.dxf.location 189 190 191class MeshPrimitive(ConvertedPrimitive): 192 def _convert_entity(self): 193 self._mesh = MeshBuilder.from_mesh(self.entity) 194 195 196class QuadrilateralPrimitive(ConvertedPrimitive): 197 def _convert_entity(self): 198 self._path = make_path(self.entity) 199 200 201class PolylinePrimitive(ConvertedPrimitive): 202 def _convert_entity(self): 203 e: 'Polyline' = cast('Polyline', self.entity) 204 if e.is_2d_polyline or e.is_3d_polyline: 205 self._path = make_path(e) 206 else: 207 m = MeshVertexMerger.from_polyface(e) 208 self._mesh = MeshBuilder.from_builder(m) 209 210 211DESCENDER_FACTOR = 0.333 # from TXT SHX font - just guessing 212X_HEIGHT_FACTOR = 0.666 # from TXT SHX font - just guessing 213 214 215def get_font_name(entity: 'DXFEntity'): 216 font_name = "txt" 217 if entity.doc: 218 style_name = entity.dxf.style 219 style = entity.doc.styles.get(style_name) 220 if style: 221 font_name = style.dxf.font 222 return font_name 223 224 225class TextLinePrimitive(ConvertedPrimitive): 226 def _convert_entity(self): 227 """ Calculates the rough border path for a single line text. 228 229 Calculation is based on a mono-spaced font and therefore the border 230 path is just an educated guess. 231 232 Vertical text generation and oblique angle is ignored. 233 234 """ 235 236 def text_rotation() -> float: 237 if fit_or_aligned and not p1.isclose(p2): 238 return (p2 - p1).angle 239 else: 240 return math.radians(text.dxf.rotation) 241 242 def location() -> Vec3: 243 if alignment == 'LEFT': 244 return p1 245 elif fit_or_aligned: 246 return p1.lerp(p2, factor=0.5) 247 else: 248 return p2 249 250 text = cast('Text', self.entity) 251 if text.dxftype() == 'ATTDEF': 252 # ATTDEF outside of a BLOCK renders the tag rather than the value 253 content = text.dxf.tag 254 else: 255 content = text.dxf.text 256 257 content = plain_text(content) 258 if len(content) == 0: 259 # empty path - does not render any vertices! 260 self._path = Path() 261 return 262 263 p1: Vec3 = text.dxf.insert 264 p2: Vec3 = text.dxf.align_point 265 font = fonts.make_font(get_font_name(text), text.dxf.height, 266 text.dxf.width) 267 text_line = TextLine(content, font) 268 alignment: str = text.get_align() 269 fit_or_aligned = alignment == 'FIT' or alignment == 'ALIGNED' 270 if text.dxf.halign > 2: # ALIGNED=3, MIDDLE=4, FIT=5 271 text_line.stretch(alignment, p1, p2) 272 halign, valign = unified_alignment(text) 273 mirror_x = -1 if text.is_backward else 1 274 mirror_y = -1 if text.is_upside_down else 1 275 oblique: float = math.radians(text.dxf.oblique) 276 corner_vertices = text_line.corner_vertices( 277 location(), halign, valign, 278 angle=text_rotation(), 279 scale=(mirror_x, mirror_y), 280 oblique=oblique, 281 ) 282 283 ocs = text.ocs() 284 self._path = from_vertices( 285 ocs.points_to_wcs(corner_vertices), 286 close=True, 287 ) 288 289 290class MTextPrimitive(ConvertedPrimitive): 291 def _convert_entity(self): 292 """ Calculates the rough border path for a MTEXT entity. 293 294 Calculation is based on a mono-spaced font and therefore the border 295 path is just an educated guess. 296 297 Most special features of MTEXT is not supported. 298 299 """ 300 301 def get_content() -> List[str]: 302 text = mtext.plain_text(split=False) 303 return text_wrap(text, box_width, font.text_width) 304 305 def get_max_str() -> str: 306 return max(content, key=lambda s: len(s)) 307 308 def get_rect_width() -> float: 309 if box_width: 310 return box_width 311 s = get_max_str() 312 if len(s) == 0: 313 s = " " 314 return font.text_width(s) 315 316 def get_rect_height() -> float: 317 line_height = font.measurements.total_height 318 cap_height = font.measurements.cap_height 319 # Line spacing factor: Percentage of default (3-on-5) line 320 # spacing to be applied. 321 322 # thx to mbway: multiple of cap_height between the baseline of the 323 # previous line and the baseline of the next line 324 # 3-on-5 line spacing = 5/3 = 1.67 325 line_spacing = cap_height * mtext.dxf.line_spacing_factor * 1.67 326 spacing = line_spacing - line_height 327 line_count = len(content) 328 return line_height * line_count + spacing * (line_count - 1) 329 330 def get_ucs() -> UCS: 331 """ Create local coordinate system: 332 origin = insertion point 333 z-axis = extrusion vector 334 x-axis = text_direction or text rotation, text rotation requires 335 extrusion vector == (0, 0, 1) or treatment like an OCS? 336 337 """ 338 origin = mtext.dxf.insert 339 z_axis = mtext.dxf.extrusion # default is Z_AXIS 340 x_axis = X_AXIS 341 if mtext.dxf.hasattr('text_direction'): 342 x_axis = mtext.dxf.text_direction 343 elif mtext.dxf.hasattr('rotation'): 344 # TODO: what if extrusion vector is not (0, 0, 1) 345 x_axis = Vec3.from_deg_angle(mtext.dxf.rotation) 346 z_axis = Z_AXIS 347 return UCS(origin=origin, ux=x_axis, uz=z_axis) 348 349 def get_shift_factors(): 350 halign, valign = unified_alignment(mtext) 351 shift_x = 0 352 shift_y = 0 353 if halign == const.CENTER: 354 shift_x = -0.5 355 elif halign == const.RIGHT: 356 shift_x = -1.0 357 if valign == const.MIDDLE: 358 shift_y = 0.5 359 elif valign == const.BOTTOM: 360 shift_y = 1.0 361 return shift_x, shift_y 362 363 def get_corner_vertices() -> Iterable[Vec3]: 364 """ Create corner vertices in the local working plan, where 365 the insertion point is the origin. 366 """ 367 if columns: 368 rect_width = columns.total_width 369 rect_height = columns.total_height 370 # TODO: this works only for reliable sources like AutoCAD, 371 # BricsCAD and ezdxf! So far no known column support from 372 # other DXF exporters. 373 else: 374 rect_width = mtext.dxf.get('rect_width', get_rect_width()) 375 rect_height = mtext.dxf.get('rect_height', get_rect_height()) 376 # TOP LEFT alignment: 377 vertices = [ 378 Vec3(0, 0), 379 Vec3(rect_width, 0), 380 Vec3(rect_width, -rect_height), 381 Vec3(0, -rect_height) 382 ] 383 sx, sy = get_shift_factors() 384 shift = Vec3(sx * rect_width, sy * rect_height) 385 return (v + shift for v in vertices) 386 387 mtext: "MText" = cast("MText", self.entity) 388 columns = mtext.columns 389 if columns is None: 390 box_width = mtext.dxf.get('width', 0) 391 font = fonts.make_font(get_font_name(mtext), mtext.dxf.char_height, 392 1.0) 393 content: List[str] = get_content() 394 if len(content) == 0: 395 # empty path - does not render any vertices! 396 self._path = Path() 397 return 398 ucs = get_ucs() 399 corner_vertices = get_corner_vertices() 400 self._path = from_vertices( 401 ucs.points_to_wcs(corner_vertices), 402 close=True, 403 ) 404 405 406class PathPrimitive(Primitive): 407 def __init__(self, path: Path, entity: DXFEntity, 408 max_flattening_distance=None): 409 super().__init__(entity, max_flattening_distance) 410 self._path = path 411 412 @property 413 def path(self) -> Optional[Path]: 414 return self._path 415 416 def vertices(self) -> Iterable[Vec3]: 417 yield from self._path.flattening(self.max_flattening_distance) 418 419 420class ImagePrimitive(ConvertedPrimitive): 421 def _convert_entity(self): 422 self._path = make_path(self.entity) 423 424 425class ViewportPrimitive(ConvertedPrimitive): 426 def _convert_entity(self): 427 vp = self.entity 428 if vp.dxf.status == 0: # Viewport is off 429 return # empty primitive 430 self._path = make_path(vp) 431 432 433# SHAPE is not supported, could not create any SHAPE entities in BricsCAD 434_PRIMITIVE_CLASSES = { 435 "3DFACE": QuadrilateralPrimitive, 436 "ARC": CurvePrimitive, 437 # TODO: ATTRIB and ATTDEF could contain embedded MTEXT, 438 # but this is not supported yet! 439 "ATTRIB": TextLinePrimitive, 440 "ATTDEF": TextLinePrimitive, 441 "CIRCLE": CurvePrimitive, 442 "ELLIPSE": CurvePrimitive, 443 # HATCH: Special handling required, see to_primitives() function 444 "HELIX": CurvePrimitive, 445 "IMAGE": ImagePrimitive, 446 "LINE": LinePrimitive, 447 "LWPOLYLINE": LwPolylinePrimitive, 448 "MESH": MeshPrimitive, 449 "MTEXT": MTextPrimitive, 450 "POINT": PointPrimitive, 451 "POLYLINE": PolylinePrimitive, 452 "SPLINE": CurvePrimitive, 453 "SOLID": QuadrilateralPrimitive, 454 "TEXT": TextLinePrimitive, 455 "TRACE": QuadrilateralPrimitive, 456 "VIEWPORT": ViewportPrimitive, 457 "WIPEOUT": ImagePrimitive, 458} 459 460 461def make_primitive(entity: DXFEntity, 462 max_flattening_distance=None) -> Primitive: 463 """ Factory to create path/mesh primitives. The `max_flattening_distance` 464 defines the max distance between the approximation line and the original 465 curve. Use `max_flattening_distance` to override the default value. 466 467 Returns an **empty primitive** for unsupported entities. The `empty` state 468 of a primitive can be checked by the property :attr:`is_empty`. 469 The :attr:`path` and the :attr:`mesh` attributes of an empty primitive 470 are ``None`` and the :meth:`vertices` method yields no vertices. 471 472 Returns an empty primitive for the :class:`~ezdxf.entities.Hatch` entity, 473 see docs of the :mod:`~ezdxf.disassemble` module. Use the this to create 474 multiple primitives from the HATCH boundary paths:: 475 476 primitives = list(to_primitives([hatch_entity])) 477 478 """ 479 cls = _PRIMITIVE_CLASSES.get(entity.dxftype(), EmptyPrimitive) 480 primitive = cls(entity) 481 if max_flattening_distance: 482 primitive.max_flattening_distance = max_flattening_distance 483 return primitive 484 485 486def recursive_decompose(entities: Iterable[DXFEntity]) -> Iterable[DXFEntity]: 487 """ Recursive decomposition of the given DXF entity collection into a flat 488 DXF entity stream. All block references (INSERT) and entities which provide 489 a :meth:`virtual_entities` method will be disassembled into simple DXF 490 sub-entities, therefore the returned entity stream does not contain any 491 INSERT entity. 492 493 Point entities will **not** be disassembled into DXF sub-entities, 494 as defined by the current point style $PDMODE. 495 496 These entity types include sub-entities and will be decomposed into 497 simple DXF entities: 498 499 - INSERT 500 - DIMENSION 501 - LEADER 502 - MLEADER 503 - MLINE 504 505 Decomposition of XREF, UNDERLAY and ACAD_TABLE entities is not supported. 506 507 """ 508 for entity in entities: 509 dxftype = entity.dxftype() 510 # ignore this virtual_entities() methods: 511 if dxftype in ('POINT', 'LWPOLYLINE', 'POLYLINE'): 512 yield entity 513 elif dxftype == 'INSERT': 514 entity = cast('Insert', entity) 515 if entity.mcount > 1: 516 yield from recursive_decompose(entity.multi_insert()) 517 else: 518 yield from entity.attribs 519 yield from recursive_decompose(entity.virtual_entities()) 520 elif hasattr(entity, 'virtual_entities'): 521 # could contain block references: 522 yield from recursive_decompose(entity.virtual_entities()) 523 # As long as MLeader.virtual_entities() is not implemented, 524 # use existing proxy graphic: 525 elif dxftype in ('MLEADER', 'MULTILEADER') and entity.proxy_graphic: 526 yield from ProxyGraphic( 527 entity.proxy_graphic, entity.doc).virtual_entities() 528 else: 529 yield entity 530 531 532def to_primitives(entities: Iterable[DXFEntity], 533 max_flattening_distance: float = None) -> Iterable[Primitive]: 534 """ Yields all DXF entities as path or mesh primitives. Yields 535 unsupported entities as empty primitives, see :func:`make_primitive`. 536 537 Args: 538 entities: iterable of DXF entities 539 max_flattening_distance: override the default value 540 541 """ 542 for e in entities: 543 # Special handling for HATCH required, because a HATCH entity can not be 544 # reduced into a single path or mesh. 545 if e.dxftype() == 'HATCH': 546 # noinspection PyTypeChecker 547 yield from _hatch_primitives(e, max_flattening_distance) 548 else: 549 yield make_primitive(e, max_flattening_distance) 550 551 552def _hatch_primitives( 553 hatch: 'Hatch', max_flattening_distance=None) -> Iterable[Primitive]: 554 """ Yield all HATCH boundary paths as separated Path() objects. """ 555 for p in from_hatch(hatch): 556 yield PathPrimitive( 557 p, 558 hatch, 559 max_flattening_distance 560 ) 561 562 563def to_vertices(primitives: Iterable[Primitive]) -> Iterable[Vec3]: 564 """ Yields all vertices from the given `primitives`. Paths will be flattened 565 to create the associated vertices. See also :func:`to_control_vertices` to 566 collect only the control vertices from the paths without flattening. 567 568 """ 569 for p in primitives: 570 yield from p.vertices() 571 572 573def to_paths(primitives: Iterable[Primitive]) -> Iterable[Path]: 574 """ Yields all :class:`~ezdxf.path.Path` objects from the given 575 `primitives`. Ignores primitives without a defined path. 576 577 """ 578 for prim in primitives: 579 if prim.path is not None: # lazy evaluation! 580 yield prim.path 581 582 583def to_meshes(primitives: Iterable[Primitive]) -> Iterable[MeshBuilder]: 584 """ Yields all :class:`~ezdxf.render.MeshBuilder` objects from the given 585 `primitives`. Ignores primitives without a defined mesh. 586 587 """ 588 for prim in primitives: 589 if prim.mesh is not None: 590 yield prim.mesh 591 592 593def to_control_vertices(primitives: Iterable[Primitive]) -> Iterable[ 594 Vec3]: 595 """ Yields all path control vertices and all mesh vertices from the given 596 `primitives`. Like :func:`to_vertices`, but without flattening. 597 598 """ 599 for prim in primitives: 600 # POINT has only a start point and yields from vertices()! 601 if prim.path: 602 yield from prim.path.control_vertices() 603 else: 604 yield from prim.vertices() 605