1# Copyright (c) 2019-2020 Manfred Moitzi 2# License: MIT License 3from typing import ( 4 TYPE_CHECKING, Iterable, cast, Tuple, Union, Optional, 5 Callable, Dict, 6) 7import math 8from ezdxf.lldxf import validator 9from ezdxf.lldxf.attributes import ( 10 DXFAttr, DXFAttributes, DefSubclass, XType, RETURN_DEFAULT, 11 group_code_mapping, 12) 13from ezdxf.lldxf.const import ( 14 DXF12, SUBCLASS_MARKER, DXFValueError, DXFKeyError, DXFStructureError, 15) 16from ezdxf.math import ( 17 Vec3, X_AXIS, Y_AXIS, Z_AXIS, Matrix44, OCS, UCS, NULLVEC, 18) 19from ezdxf.math.transformtools import OCSTransform, InsertTransformationError 20from ezdxf.explode import ( 21 explode_block_reference, virtual_block_reference_entities, 22) 23from ezdxf.entities import factory 24from ezdxf.query import EntityQuery 25from ezdxf.audit import AuditError 26from .dxfentity import base_class, SubclassProcessor 27from .dxfgfx import DXFGraphic, acdb_entity, elevation_to_z_axis 28from .subentity import LinkedEntities 29from .attrib import Attrib 30 31if TYPE_CHECKING: 32 from ezdxf.eztypes import ( 33 TagWriter, Vertex, DXFNamespace, AttDef, BlockLayout, BaseLayout, 34 Auditor, 35 ) 36 37__all__ = ['Insert'] 38 39ABS_TOL = 1e-9 40 41# Multi-INSERT has subclass id AcDbMInsertBlock 42acdb_block_reference = DefSubclass('AcDbBlockReference', { 43 'attribs_follow': DXFAttr(66, default=0, optional=True), 44 'name': DXFAttr(2, validator=validator.is_valid_block_name), 45 'insert': DXFAttr(10, xtype=XType.any_point), 46 47 # Elevation is a legacy feature from R11 and prior, do not use this 48 # attribute, store the entity elevation in the z-axis of the vertices. 49 # ezdxf does not export the elevation attribute! 50 'elevation': DXFAttr(38, default=0, optional=True), 51 52 'xscale': DXFAttr( 53 41, default=1, optional=True, 54 validator=validator.is_not_zero, 55 fixer=RETURN_DEFAULT, 56 ), 57 'yscale': DXFAttr( 58 42, default=1, optional=True, 59 validator=validator.is_not_zero, 60 fixer=RETURN_DEFAULT, 61 ), 62 'zscale': DXFAttr( 63 43, default=1, optional=True, 64 validator=validator.is_not_zero, 65 fixer=RETURN_DEFAULT, 66 ), 67 'rotation': DXFAttr(50, default=0, optional=True), 68 'column_count': DXFAttr( 69 70, default=1, optional=True, 70 validator=validator.is_greater_zero, 71 fixer=RETURN_DEFAULT, 72 ), 73 'row_count': DXFAttr( 74 71, default=1, optional=True, 75 validator=validator.is_greater_zero, 76 fixer=RETURN_DEFAULT, 77 ), 78 'column_spacing': DXFAttr(44, default=0, optional=True), 79 'row_spacing': DXFAttr(45, default=0, optional=True), 80 'extrusion': DXFAttr( 81 210, xtype=XType.point3d, default=Z_AXIS, optional=True, 82 validator=validator.is_not_null_vector, 83 fixer=RETURN_DEFAULT, 84 ), 85}) 86acdb_block_reference_group_codes = group_code_mapping(acdb_block_reference) 87NON_ORTHO_MSG = 'INSERT entity can not represent a non-orthogonal target ' \ 88 'coordinate system.' 89 90 91# Notes to SEQEND: 92# 93# The INSERT entity requires only a SEQEND if ATTRIB entities are attached. 94# So a loaded INSERT could have a missing SEQEND. 95# 96# A bounded INSERT needs a SEQEND to be valid at export if there are attached 97# ATTRIB entities, but the LinkedEntities.post_bind_hook() method creates 98# always a new SEQEND after binding the INSERT entity to a document. 99# 100# Nonetheless the Insert.add_attrib() method also creates a requires SEQEND if 101# necessary. 102 103@factory.register_entity 104class Insert(LinkedEntities): 105 """ DXF INSERT entity """ 106 DXFTYPE = 'INSERT' 107 DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_block_reference) 108 109 @property 110 def attribs(self): 111 return self._sub_entities 112 113 @property 114 def attribs_follow(self) -> bool: 115 return bool(len(self.attribs)) 116 117 def load_dxf_attribs(self, 118 processor: SubclassProcessor = None) -> 'DXFNamespace': 119 dxf = super().load_dxf_attribs(processor) 120 if processor: 121 # Always use the 2nd subclass, could be AcDbBlockReference or 122 # AcDbMInsertBlock: 123 processor.fast_load_dxfattribs( 124 dxf, acdb_block_reference_group_codes, 2, recover=True) 125 if processor.r12: 126 # Transform elevation attribute from R11 to z-axis values: 127 elevation_to_z_axis(dxf, ('insert',)) 128 return dxf 129 130 def export_entity(self, tagwriter: 'TagWriter') -> None: 131 """ Export entity specific data as DXF tags. """ 132 super().export_entity(tagwriter) 133 if tagwriter.dxfversion > DXF12: 134 if (self.dxf.column_count > 1) or (self.dxf.row_count > 1): 135 tagwriter.write_tag2(SUBCLASS_MARKER, 'AcDbMInsertBlock') 136 else: 137 tagwriter.write_tag2(SUBCLASS_MARKER, 'AcDbBlockReference') 138 if self.attribs_follow: 139 tagwriter.write_tag2(66, 1) 140 self.dxf.export_dxf_attribs(tagwriter, [ 141 'name', 'insert', 'xscale', 'yscale', 'zscale', 'rotation', 142 'column_count', 'row_count', 'column_spacing', 'row_spacing', 143 'extrusion', 144 ]) 145 146 def export_dxf(self, tagwriter: 'TagWriter'): 147 super().export_dxf(tagwriter) 148 # Do no export SEQEND if no ATTRIBS attached: 149 if self.attribs_follow: 150 self.process_sub_entities(lambda e: e.export_dxf(tagwriter)) 151 152 @property 153 def has_scaling(self) -> bool: 154 """ Returns ``True`` if any axis scaling is applied. """ 155 if self.dxf.hasattr('xscale') and self.dxf.xscale != 1: 156 return True 157 if self.dxf.hasattr('yscale') and self.dxf.yscale != 1: 158 return True 159 if self.dxf.hasattr('zscale') and self.dxf.zscale != 1: 160 return True 161 return False 162 163 @property 164 def has_uniform_scaling(self) -> bool: 165 """ Returns ``True`` if scaling is uniform in x-, y- and z-axis ignoring 166 reflections e.g. (1, 1, -1) is uniform scaling. 167 168 """ 169 return abs(self.dxf.xscale) == abs(self.dxf.yscale) == abs( 170 self.dxf.zscale) 171 172 def set_scale(self, factor: float): 173 """ Set uniform scaling. """ 174 if factor == 0: 175 raise ValueError('Invalid scaling factor.') 176 self.dxf.xscale = factor 177 self.dxf.yscale = factor 178 self.dxf.zscale = factor 179 return self 180 181 def is_xref(self) -> bool: 182 """ Return ``True`` if XREF or XREF_OVERLAY. """ 183 assert self.doc is not None, 'Requires a document object' 184 block_layout = self.doc.blocks.get(self.dxf.name) 185 if block_layout is not None and block_layout.block.dxf.flags & 12: # XREF(4) & XREF_OVERLAY(8) 186 return True 187 return False 188 189 def block(self) -> Optional['BlockLayout']: 190 """ Returns associated :class:`~ezdxf.layouts.BlockLayout`. """ 191 if self.doc is None: 192 return None 193 return self.doc.blocks.get(self.dxf.name) 194 195 def place(self, insert: 'Vertex' = None, 196 scale: Tuple[float, float, float] = None, 197 rotation: float = None) -> 'Insert': 198 """ 199 Set block reference placing location `insert`, scaling and rotation 200 attributes. Parameters which are ``None`` will not be altered. 201 202 Args: 203 insert: insert location as ``(x, y [,z])`` tuple 204 scale: ``(x-scale, y-scale, z-scale)`` tuple 205 rotation : rotation angle in degrees 206 207 """ 208 if insert is not None: 209 self.dxf.insert = insert 210 if scale is not None: 211 if len(scale) != 3: 212 raise DXFValueError( 213 "Parameter scale has to be a (x, y, z)-tuple." 214 ) 215 x, y, z = scale 216 self.dxf.xscale = x 217 self.dxf.yscale = y 218 self.dxf.zscale = z 219 if rotation is not None: 220 self.dxf.rotation = rotation 221 return self 222 223 def grid(self, size: Tuple[int, int] = (1, 1), 224 spacing: Tuple[float, float] = (1, 1)) -> 'Insert': 225 """ Place block reference in a grid layout, grid `size` defines the 226 row- and column count, `spacing` defines the distance between two block 227 references. 228 229 Args: 230 size: grid size as ``(row_count, column_count)`` tuple 231 spacing: distance between placing as 232 ``(row_spacing, column_spacing)`` tuple 233 234 """ 235 try: 236 rows, cols = size 237 except ValueError: 238 raise DXFValueError( 239 "Size has to be a 2-tuple: (row_count, column_count)." 240 ) 241 self.dxf.row_count = rows 242 self.dxf.column_count = cols 243 try: 244 row_spacing, col_spacing = spacing 245 except ValueError: 246 raise DXFValueError( 247 "Spacing has to be a 2-tuple: (row_spacing, column_spacing)." 248 ) 249 self.dxf.row_spacing = row_spacing 250 self.dxf.column_spacing = col_spacing 251 return self 252 253 def get_attrib(self, tag: str, search_const: bool = False) -> Optional[ 254 Union['Attrib', 'AttDef']]: 255 """ Get attached :class:`Attrib` entity with :code:`dxf.tag == tag`, 256 returns ``None`` if not found. Some applications may not attach constant 257 ATTRIB entities, set `search_const` to ``True``, to get at least the 258 associated :class:`AttDef` entity. 259 260 Args: 261 tag: tag name 262 search_const: search also const ATTDEF entities 263 264 """ 265 for attrib in self.attribs: 266 if tag == attrib.dxf.tag: 267 return attrib 268 if search_const and self.doc is not None: 269 block = self.doc.blocks[self.dxf.name] 270 for attdef in block.get_const_attdefs(): 271 if tag == attdef.dxf.tag: 272 return attdef 273 return None 274 275 def get_attrib_text(self, tag: str, default: str = None, 276 search_const: bool = False) -> str: 277 """ Get content text of attached :class:`Attrib` entity with 278 :code:`dxf.tag == tag`, returns `default` if not found. 279 Some applications may not attach constant ATTRIB entities, set 280 `search_const` to ``True``, to get content text of the 281 associated :class:`AttDef` entity. 282 283 Args: 284 tag: tag name 285 default: default value if ATTRIB `tag` is absent 286 search_const: search also const ATTDEF entities 287 288 """ 289 attrib = self.get_attrib(tag, search_const) 290 if attrib is None: 291 return default 292 return attrib.dxf.text 293 294 def has_attrib(self, tag: str, search_const: bool = False) -> bool: 295 """ Returns ``True`` if ATTRIB `tag` exist, for `search_const` doc see 296 :meth:`get_attrib`. 297 298 Args: 299 tag: tag name as string 300 search_const: search also const ATTDEF entities 301 302 """ 303 return self.get_attrib(tag, search_const) is not None 304 305 def add_attrib(self, tag: str, text: str, insert: 'Vertex' = (0, 0), 306 dxfattribs: dict = None) -> 'Attrib': 307 """ Attach an :class:`Attrib` entity to the block reference. 308 309 Example for appending an attribute to an INSERT entity with none 310 standard alignment:: 311 312 e.add_attrib('EXAMPLETAG', 'example text').set_pos( 313 (3, 7), align='MIDDLE_CENTER' 314 ) 315 316 Args: 317 tag: tag name as string 318 text: content text as string 319 insert: insert location as tuple ``(x, y[, z])`` in :ref:`WCS` 320 dxfattribs: additional DXF attributes for the ATTRIB entity 321 322 """ 323 dxfattribs = dxfattribs or {} 324 dxfattribs['tag'] = tag 325 dxfattribs['text'] = text 326 dxfattribs['insert'] = insert 327 attrib = cast('Attrib', 328 self._new_compound_entity('ATTRIB', dxfattribs)) 329 self.attribs.append(attrib) 330 331 # This case is only possible if INSERT is read from file without 332 # attached ATTRIBS: 333 if self.seqend is None: 334 self.new_seqend() 335 return attrib 336 337 def delete_attrib(self, tag: str, ignore=False) -> None: 338 """ Delete an attached :class:`Attrib` entity from INSERT. If `ignore` 339 is ``False``, an :class:`DXFKeyError` exception is raised, if 340 ATTRIB `tag` does not exist. 341 342 Args: 343 tag: ATTRIB name 344 ignore: ``False`` for raising :class:`DXFKeyError` if ATTRIB `tag` 345 does not exist. 346 347 Raises: 348 DXFKeyError: if ATTRIB `tag` does not exist. 349 350 """ 351 for index, attrib in enumerate(self.attribs): 352 if attrib.dxf.tag == tag: 353 del self.attribs[index] 354 attrib.destroy() 355 return 356 if not ignore: 357 raise DXFKeyError(tag) 358 359 def delete_all_attribs(self) -> None: 360 """ Delete all :class:`Attrib` entities attached to the INSERT entity. 361 """ 362 if not self.is_alive: 363 return 364 365 for attrib in self.attribs: 366 attrib.destroy() 367 self._sub_entities = [] 368 369 def transform(self, m: 'Matrix44') -> 'Insert': 370 """ Transform INSERT entity by transformation matrix `m` inplace. 371 372 Unlike the transformation matrix `m`, the INSERT entity can not 373 represent a non orthogonal target coordinate system, for this case an 374 :class:`InsertTransformationError` will be raised. 375 376 """ 377 378 dxf = self.dxf 379 ocs = self.ocs() 380 381 # Transform source OCS axis into the target coordinate system: 382 ux, uy, uz = m.transform_directions((ocs.ux, ocs.uy, ocs.uz)) 383 384 # Calculate new axis scaling factors: 385 x_scale = ux.magnitude * dxf.xscale 386 y_scale = uy.magnitude * dxf.yscale 387 z_scale = uz.magnitude * dxf.zscale 388 389 ux = ux.normalize() 390 uy = uy.normalize() 391 uz = uz.normalize() 392 # check for orthogonal x-, y- and z-axis 393 if (abs(ux.dot(uz)) > ABS_TOL or abs(ux.dot(uy)) > ABS_TOL or 394 abs(uz.dot(uy)) > ABS_TOL): 395 raise InsertTransformationError(NON_ORTHO_MSG) 396 397 # expected y-axis for an orthogonal right handed coordinate system: 398 expected_uy = uz.cross(ux) 399 if not expected_uy.isclose(uy, abs_tol=ABS_TOL): 400 # new y-axis points into opposite direction: 401 y_scale = -y_scale 402 403 ocs = OCSTransform.from_ocs(OCS(dxf.extrusion), OCS(uz), m) 404 dxf.insert = ocs.transform_vertex(dxf.insert) 405 dxf.rotation = ocs.transform_deg_angle(dxf.rotation) 406 407 dxf.extrusion = uz 408 dxf.xscale = x_scale 409 dxf.yscale = y_scale 410 dxf.zscale = z_scale 411 412 for attrib in self.attribs: 413 attrib.transform(m) 414 return self 415 416 def translate(self, dx: float, dy: float, dz: float) -> 'Insert': 417 """ Optimized INSERT translation about `dx` in x-axis, `dy` in y-axis 418 and `dz` in z-axis. 419 420 """ 421 ocs = self.ocs() 422 self.dxf.insert = ocs.from_wcs( 423 Vec3(dx, dy, dz) + ocs.to_wcs(self.dxf.insert)) 424 for attrib in self.attribs: 425 attrib.translate(dx, dy, dz) 426 return self 427 428 def matrix44(self) -> Matrix44: 429 """ Returns a transformation matrix of type :class:`Matrix44` to 430 transform the block entities into :ref:`WCS`. 431 432 """ 433 dxf = self.dxf 434 sx = dxf.xscale 435 sy = dxf.yscale 436 sz = dxf.zscale 437 438 ocs = self.ocs() 439 extrusion = ocs.uz 440 ux = Vec3(ocs.to_wcs(X_AXIS)) 441 uy = Vec3(ocs.to_wcs(Y_AXIS)) 442 m = Matrix44.ucs(ux=ux * sx, uy=uy * sy, uz=extrusion * sz) 443 # same as Matrix44.ucs(ux, uy, extrusion) * Matrix44.scale(sx, sy, sz) 444 445 angle = math.radians(dxf.rotation) 446 if angle: 447 m *= Matrix44.axis_rotate(extrusion, angle) 448 449 insert = ocs.to_wcs(dxf.get('insert', Vec3())) 450 451 block_layout = self.block() 452 if block_layout is not None: 453 # transform block base point into WCS without translation 454 insert -= m.transform_direction(block_layout.block.dxf.base_point) 455 456 # set translation 457 m.set_row(3, insert.xyz) 458 return m 459 460 def ucs(self): 461 """ Returns the block reference coordinate system as 462 :class:`ezdxf.math.UCS` object. 463 """ 464 m = self.matrix44() 465 ucs = UCS() 466 ucs.matrix = m 467 return ucs 468 469 def reset_transformation(self) -> None: 470 """ Reset block reference parameters `location`, `rotation` and 471 `extrusion` vector. 472 473 """ 474 self.dxf.insert = NULLVEC 475 self.dxf.discard('rotation') 476 self.dxf.discard('extrusion') 477 478 def explode(self, target_layout: 'BaseLayout' = None) -> 'EntityQuery': 479 """ Explode block reference entities into target layout, if target 480 layout is ``None``, the target layout is the layout of the block 481 reference. This method destroys the source block reference entity. 482 483 Transforms the block entities into the required :ref:`WCS` location by 484 applying the block reference attributes `insert`, `extrusion`, 485 `rotation` and the scaling values `xscale`, `yscale` and `zscale`. 486 487 Attached ATTRIB entities are converted to TEXT entities, this is the 488 behavior of the BURST command of the AutoCAD Express Tools. 489 490 Returns an :class:`~ezdxf.query.EntityQuery` container with all 491 "exploded" DXF entities. 492 493 .. warning:: 494 495 **Non uniform scaling** may lead to incorrect results for text 496 entities (TEXT, MTEXT, ATTRIB) and maybe some other entities. 497 498 Args: 499 target_layout: target layout for exploded entities, ``None`` for 500 same layout as source entity. 501 502 """ 503 if target_layout is None: 504 target_layout = self.get_layout() 505 if target_layout is None: 506 raise DXFStructureError( 507 'INSERT without layout assigment, specify target layout.' 508 ) 509 return explode_block_reference(self, target_layout=target_layout) 510 511 def virtual_entities(self, 512 skipped_entity_callback: Optional[ 513 Callable[[DXFGraphic, str], None]] = None 514 ) -> Iterable[DXFGraphic]: 515 """ 516 Yields "virtual" entities of a block reference. This method is meant to 517 examine the block reference entities at the "exploded" location without 518 really "exploding" the block reference. The`skipped_entity_callback()` 519 will be called for all entities which are not processed, signature: 520 :code:`skipped_entity_callback(entity: DXFEntity, reason: str)`, 521 `entity` is the original (untransformed) DXF entity of the block 522 definition, the `reason` string is an explanation why the entity was 523 skipped. 524 525 This entities are not stored in the entity database, have no handle and 526 are not assigned to any layout. It is possible to convert this entities 527 into regular drawing entities by adding the entities to the entities 528 database and a layout of the same DXF document as the block reference:: 529 530 doc.entitydb.add(entity) 531 msp = doc.modelspace() 532 msp.add_entity(entity) 533 534 This method does not resolve the MINSERT attributes, only the 535 sub-entities of the base INSERT will be returned. To resolve MINSERT 536 entities check if multi insert processing is required, that's the case 537 if property :attr:`Insert.mcount` > 1, use the :meth:`Insert.multi_insert` 538 method to resolve the MINSERT entity into single INSERT entities. 539 540 .. warning:: 541 542 **Non uniform scaling** may return incorrect results for text 543 entities (TEXT, MTEXT, ATTRIB) and maybe some other entities. 544 545 Args: 546 skipped_entity_callback: called whenever the transformation of an 547 entity is not supported and so was skipped 548 549 """ 550 return virtual_block_reference_entities( 551 self, skipped_entity_callback=skipped_entity_callback) 552 553 @property 554 def mcount(self): 555 """ Returns the multi-insert count, MINSERT (multi-insert) processing 556 is required if :attr:`mcount` > 1. 557 558 .. versionadded:: 0.14 559 560 """ 561 return (self.dxf.row_count if self.dxf.row_spacing else 1) * ( 562 self.dxf.column_count if self.dxf.column_spacing else 1) 563 564 def multi_insert(self) -> Iterable['Insert']: 565 """ Yields a virtual INSERT entity for each grid element of a MINSERT 566 entity (multi-insert). 567 568 .. versionadded:: 0.14 569 570 """ 571 572 def transform_attached_attrib_entities(insert, offset): 573 for attrib in insert.attribs: 574 attrib.dxf.insert += offset 575 576 def adjust_dxf_attribs(insert, offset): 577 dxf = insert.dxf 578 dxf.insert += offset 579 dxf.discard('row_count') 580 dxf.discard('column_count') 581 dxf.discard('row_spacing') 582 dxf.discard('column_spacing') 583 584 done = set() 585 row_spacing = self.dxf.row_spacing 586 col_spacing = self.dxf.column_spacing 587 rotation = self.dxf.rotation 588 for row in range(self.dxf.row_count): 589 for col in range(self.dxf.column_count): 590 # All transformations in OCS: 591 offset = Vec3(col * col_spacing, row * row_spacing) 592 # If any spacing is 0, yield only unique locations: 593 if offset not in done: 594 done.add(offset) 595 if rotation: # Apply rotation to the grid. 596 offset = offset.rotate_deg(rotation) 597 # Do not apply scaling to the grid! 598 insert = self.copy() 599 adjust_dxf_attribs(insert, offset) 600 transform_attached_attrib_entities(insert, offset) 601 yield insert 602 603 def add_auto_attribs(self, values: Dict[str, str]) -> 'Insert': 604 """ 605 Attach for each :class:`~ezdxf.entities.Attdef` entity, defined in the 606 block definition, automatically an :class:`Attrib` entity to the block 607 reference and set ``tag/value`` DXF attributes of the ATTRIB entities 608 by the ``key/value`` pairs (both as strings) of the `values` dict. 609 The ATTRIB entities are placed relative to the insert location of the 610 block reference, which is identical to the block base point. 611 612 This method avoids the wrapper block of the 613 :meth:`~ezdxf.layouts.BaseLayout.add_auto_blockref` method, but the 614 visual results may not match the results of CAD applications, especially 615 for non uniform scaling. If the visual result is very important to you, 616 use the :meth:`add_auto_blockref` method. 617 618 Args: 619 values: :class:`~ezdxf.entities.Attrib` tag values as ``tag/value`` 620 pairs 621 622 """ 623 624 def unpack(dxfattribs) -> Tuple[str, str, 'Vertex']: 625 tag = dxfattribs.pop('tag') 626 text = values.get(tag, "") 627 location = dxfattribs.pop('insert') 628 return tag, text, location 629 630 def autofill() -> None: 631 for attdef in blockdef.attdefs(): 632 dxfattribs = attdef.dxfattribs(drop={'prompt', 'handle'}) 633 tag, text, location = unpack(dxfattribs) 634 attrib = self.add_attrib(tag, text, location, dxfattribs) 635 attrib.transform(m) 636 637 m = self.matrix44() 638 blockdef = self.block() 639 autofill() 640 return self 641 642 def audit(self, auditor: 'Auditor') -> None: 643 """ Validity check. """ 644 super().audit(auditor) 645 doc = auditor.doc 646 if doc and doc.blocks: 647 if self.dxf.name not in doc.blocks: 648 auditor.fixed_error( 649 code=AuditError.UNDEFINED_BLOCK, 650 message=f'Deleted entity {str(self)} without required BLOCK' 651 f' definition.', 652 ) 653 auditor.trash(self) 654