1# Copyright (c) 2019-2020 Manfred Moitzi 2# License: MIT License 3from typing import ( 4 TYPE_CHECKING, Iterable, List, cast, Optional, Callable, Dict, 5) 6import logging 7from ezdxf.lldxf import validator 8from ezdxf.lldxf.attributes import ( 9 DXFAttr, DXFAttributes, DefSubclass, XType, RETURN_DEFAULT, 10 group_code_mapping, 11) 12from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2000, DXF2010 13from ezdxf.math import Vec3, Vec2, BoundingBox2d 14from .dxfentity import base_class, SubclassProcessor 15from .dxfgfx import DXFGraphic, acdb_entity 16from .dxfobj import DXFObject 17from .factory import register_entity 18 19logger = logging.getLogger('ezdxf') 20 21if TYPE_CHECKING: 22 from ezdxf.eztypes import ( 23 TagWriter, DXFNamespace, Drawing, Vertex, DXFTag, Matrix44, Auditor, 24 ) 25 26__all__ = ['Image', 'ImageDef', 'ImageDefReactor', 'RasterVariables', 'Wipeout'] 27 28 29class ImageBase(DXFGraphic): 30 """ DXF IMAGE entity """ 31 DXFTYPE = 'IMAGEBASE' 32 _CLS_GROUP_CODES = dict() 33 _SUBCLASS_NAME = 'dummy' 34 MIN_DXF_VERSION_FOR_EXPORT = DXF2000 35 36 SHOW_IMAGE = 1 37 SHOW_IMAGE_WHEN_NOT_ALIGNED = 2 38 USE_CLIPPING_BOUNDARY = 4 39 USE_TRANSPARENCY = 8 40 41 def __init__(self): 42 super().__init__() 43 # Boundary/Clipping path coordinates: 44 # 0/0 is in the Left/Top corner of the image! 45 # x-coordinates increases in u_pixel vector direction 46 # y-coordinates increases against the v_pixel vector! 47 # see also WCS coordinate calculation 48 self._boundary_path: List[Vec2] = [] 49 50 def _copy_data(self, entity: 'ImageBase') -> None: 51 entity._boundary_path = list(self._boundary_path) 52 53 def post_new_hook(self) -> None: 54 super().post_new_hook() 55 self.reset_boundary_path() 56 57 def load_dxf_attribs( 58 self, processor: SubclassProcessor = None) -> 'DXFNamespace': 59 dxf = super().load_dxf_attribs(processor) 60 if processor: 61 path_tags = processor.subclasses[2].pop_tags(codes=(14,)) 62 self.load_boundary_path(path_tags) 63 processor.fast_load_dxfattribs( 64 dxf, self._CLS_GROUP_CODES, 2, recover=True) 65 if len(self.boundary_path) < 2: # something is wrong 66 self.dxf = dxf 67 self.reset_boundary_path() 68 return dxf 69 70 def load_boundary_path(self, tags: Iterable['DXFTag']): 71 self._boundary_path = [ 72 Vec2(value) for code, value in tags if code == 14 73 ] 74 75 def export_entity(self, tagwriter: 'TagWriter') -> None: 76 """ Export entity specific data as DXF tags. """ 77 super().export_entity(tagwriter) 78 tagwriter.write_tag2(SUBCLASS_MARKER, self._SUBCLASS_NAME) 79 self.dxf.count_boundary_points = len(self.boundary_path) 80 self.dxf.export_dxf_attribs(tagwriter, [ 81 'class_version', 'insert', 'u_pixel', 'v_pixel', 'image_size', 82 'image_def_handle', 'flags', 'clipping', 'brightness', 'contrast', 83 'fade', 'image_def_reactor_handle', 'clipping_boundary_type', 84 'count_boundary_points', 85 ]) 86 self.export_boundary_path(tagwriter) 87 if tagwriter.dxfversion >= DXF2010: 88 self.dxf.export_dxf_attribs(tagwriter, 'clip_mode') 89 90 def export_boundary_path(self, tagwriter: 'TagWriter'): 91 for vertex in self.boundary_path: 92 tagwriter.write_vertex(14, vertex) 93 94 @property 95 def boundary_path(self): 96 """ A list of vertices as pixel coordinates, Two vertices describe a 97 rectangle, lower left corner is ``(-0.5, -0.5)`` and upper right corner 98 is ``(ImageSizeX-0.5, ImageSizeY-0.5)``, more than two vertices is a 99 polygon as clipping path. All vertices as pixel coordinates. (read/write) 100 101 """ 102 return self._boundary_path 103 104 @boundary_path.setter 105 def boundary_path(self, vertices: Iterable['Vertex']) -> None: 106 self.set_boundary_path(vertices) 107 108 def set_boundary_path(self, vertices: Iterable['Vertex']) -> None: 109 """ Set boundary path to `vertices`. Two vertices describe a rectangle 110 (lower left and upper right corner), more than two vertices is a polygon 111 as clipping path. 112 113 """ 114 vertices = Vec2.list(vertices) 115 if len(vertices): 116 if len(vertices) > 2 and not vertices[-1].isclose(vertices[0]): 117 # Close path, otherwise AutoCAD crashes 118 vertices.append(vertices[0]) 119 self._boundary_path = vertices 120 self.set_flag_state(self.USE_CLIPPING_BOUNDARY, state=True) 121 self.dxf.clipping = 1 122 self.dxf.clipping_boundary_type = 1 if len(vertices) < 3 else 2 123 self.dxf.count_boundary_points = len(self._boundary_path) 124 else: 125 self.reset_boundary_path() 126 127 def reset_boundary_path(self) -> None: 128 """ Reset boundary path to the default rectangle [(-0.5, -0.5), 129 (ImageSizeX-0.5, ImageSizeY-0.5)]. 130 131 """ 132 lower_left_corner = Vec2(-.5, -.5) 133 upper_right_corner = Vec2(self.dxf.image_size) + lower_left_corner 134 self._boundary_path = [lower_left_corner, upper_right_corner] 135 self.set_flag_state(Image.USE_CLIPPING_BOUNDARY, state=False) 136 self.dxf.clipping = 0 137 self.dxf.clipping_boundary_type = 1 138 self.dxf.count_boundary_points = 2 139 140 def transform(self, m: 'Matrix44') -> 'ImageBase': 141 """ Transform IMAGE entity by transformation matrix `m` inplace. """ 142 self.dxf.insert = m.transform(self.dxf.insert) 143 self.dxf.u_pixel = m.transform_direction(self.dxf.u_pixel) 144 self.dxf.v_pixel = m.transform_direction(self.dxf.v_pixel) 145 return self 146 147 def boundary_path_wcs(self) -> List[Vec3]: 148 """ Returns the boundary/clipping path in WCS coordinates. 149 150 .. versionadded:: 0.14 151 152 Since version 0.16 it's recommended to create the clipping path 153 as :class:`~ezdxf.path.Path` object by the 154 :func:`~ezdxf.path.make_path` function:: 155 156 form ezdxf.path import make_path 157 158 image = ... # get image entity 159 clipping_path = make_path(image) 160 161 """ 162 163 u = Vec3(self.dxf.u_pixel) 164 v = Vec3(self.dxf.v_pixel) 165 origin = Vec3(self.dxf.insert) 166 origin += (u * 0.5 - v * 0.5) 167 height = self.dxf.image_size.y 168 boundary_path = self.boundary_path 169 if len(boundary_path) == 2: # rectangle 170 p0, p1 = boundary_path 171 boundary_path = [p0, Vec2(p1.x, p0.y), p1, Vec2(p0.x, p1.y)] 172 # Boundary/Clipping path origin 0/0 is in the Left/Top corner 173 # of the image! 174 vertices = [ 175 origin + (u * p.x) + (v * (height - p.y)) for p in boundary_path 176 ] 177 if not vertices[0].isclose(vertices[-1]): 178 vertices.append(vertices[0]) 179 return vertices 180 181 def destroy(self) -> None: 182 if not self.is_alive: 183 return 184 185 del self._boundary_path 186 super().destroy() 187 188 189acdb_image = DefSubclass('AcDbRasterImage', { 190 'class_version': DXFAttr(90, dxfversion=DXF2000, default=0), 191 'insert': DXFAttr(10, xtype=XType.point3d), 192 193 # U-vector of a single pixel (points along the visual bottom of the image, 194 # starting at the insertion point) 195 'u_pixel': DXFAttr(11, xtype=XType.point3d), 196 197 # V-vector of a single pixel (points along the visual left side of the 198 # image, starting at the insertion point) 199 'v_pixel': DXFAttr(12, xtype=XType.point3d), 200 201 # Image size in pixels 202 'image_size': DXFAttr(13, xtype=XType.point2d), 203 204 # Hard reference to image def object 205 'image_def_handle': DXFAttr(340), 206 207 # Image display properties: 208 # 1 = Show image 209 # 2 = Show image when not aligned with screen 210 # 4 = Use clipping boundary 211 # 8 = Transparency is on 212 'flags': DXFAttr(70, default=3), 213 214 # Clipping state: 215 # 0 = Off 216 # 1 = On 217 'clipping': DXFAttr( 218 280, default=0, 219 validator=validator.is_integer_bool, 220 fixer=RETURN_DEFAULT, 221 ), 222 223 # Brightness value (0-100; default = 50) 224 'brightness': DXFAttr( 225 281, default=50, 226 validator=validator.is_in_integer_range(0, 101), 227 fixer=validator.fit_into_integer_range(0, 101), 228 ), 229 230 # Contrast value (0-100; default = 50) 231 'contrast': DXFAttr( 232 282, default=50, 233 validator=validator.is_in_integer_range(0, 101), 234 fixer=validator.fit_into_integer_range(0, 101), 235 ), 236 # Fade value (0-100; default = 0) 237 'fade': DXFAttr( 238 283, default=0, 239 validator=validator.is_in_integer_range(0, 101), 240 fixer=validator.fit_into_integer_range(0, 101), 241 ), 242 243 # Hard reference to image def reactor object, not required by AutoCAD 244 'image_def_reactor_handle': DXFAttr(360), 245 246 # Clipping boundary type: 247 # 1 = Rectangular 248 # 2 = Polygonal 249 'clipping_boundary_type': DXFAttr( 250 71, default=1, 251 validator=validator.is_one_of({1, 2}), 252 fixer=RETURN_DEFAULT 253 ), 254 255 # Number of clip boundary vertices that follow 256 'count_boundary_points': DXFAttr(91), 257 258 # Clip mode: 259 # 0 = outside 260 # 1 = inside mode 261 'clip_mode': DXFAttr( 262 290, dxfversion=DXF2010, default=0, 263 validator=validator.is_integer_bool, 264 fixer=RETURN_DEFAULT, 265 ), 266 # boundary path coordinates are pixel coordinates NOT drawing units 267}) 268acdb_image_group_codes = group_code_mapping(acdb_image) 269 270 271@register_entity 272class Image(ImageBase): 273 """ DXF IMAGE entity """ 274 DXFTYPE = 'IMAGE' 275 DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_image) 276 _CLS_GROUP_CODES = acdb_image_group_codes 277 _SUBCLASS_NAME = acdb_image.name 278 DEFAULT_ATTRIBS = {'layer': '0', 'flags': 3} 279 280 def __init__(self): 281 super().__init__() 282 self._boundary_path: List[Vec2] = [] 283 self._image_def: Optional[ImageDef] = None 284 self._image_def_reactor: Optional[ImageDefReactor] = None 285 286 @classmethod 287 def new(cls: 'Image', handle: str = None, owner: str = None, 288 dxfattribs: Dict = None, doc: 'Drawing' = None) -> 'Image': 289 dxfattribs = dxfattribs or {} 290 # 'image_def' is not a real DXF attribute (image_def_handle) 291 image_def = dxfattribs.pop('image_def', None) 292 image_size = (1, 1) 293 if image_def and image_def.is_alive: 294 image_size = image_def.dxf.image_size 295 dxfattribs.setdefault('image_size', image_size) 296 297 image = cast('Image', super().new(handle, owner, dxfattribs, doc)) 298 image.image_def = image_def 299 return image 300 301 def copy(self) -> 'Image': 302 image_copy = cast('Image', super().copy()) 303 # Each Image has its own ImageDefReactor object, 304 # which will be created by binding the copy to the 305 # document. 306 image_copy.dxf.discard('image_def_reactor_handle') 307 image_copy._image_def_reactor = None 308 image_copy._image_def = self._image_def 309 return image_copy 310 311 def post_bind_hook(self) -> None: 312 # Document in LOAD process -> post_load_hook() 313 if self.doc.is_loading: 314 return 315 if self._image_def_reactor: # ImageDefReactor already exist 316 return 317 # The new Image was created by ezdxf and the ImageDefReactor 318 # object does not exist: 319 self._create_image_def_reactor() 320 321 def post_load_hook(self, doc: 'Drawing') -> Optional[Callable]: 322 super().post_load_hook(doc) 323 db = doc.entitydb 324 self._image_def = db.get(self.dxf.get('image_def_handle', None)) 325 if self._image_def is None: 326 # unrecoverable structure error 327 self.destroy() 328 return 329 330 self._image_def_reactor = db.get(self.dxf.get( 331 'image_def_reactor_handle', None)) 332 if self._image_def_reactor is None: 333 # Image and ImageDef exist - this is recoverable by creating 334 # an ImageDefReactor, but the objects section does not exist yet! 335 # Return a post init command: 336 return self._fix_missing_image_def_reactor 337 338 def _fix_missing_image_def_reactor(self): 339 try: 340 self._create_image_def_reactor() 341 except Exception as e: 342 logger.exception( 343 f'An exception occurred while executing fixing command for ' 344 f'{str(self)}, destroying entity.', 345 exc_info=e, 346 ) 347 self.destroy() 348 return 349 logger.debug(f'Created missing ImageDefReactor for {str(self)}') 350 351 def _create_image_def_reactor(self): 352 # ImageDef -> ImageDefReactor -> Image 353 image_def_reactor = self.doc.objects.add_image_def_reactor( 354 self.dxf.handle) 355 reactor_handle = image_def_reactor.dxf.handle 356 # Link Image to ImageDefReactor: 357 self.dxf.image_def_reactor_handle = reactor_handle 358 self._image_def_reactor = image_def_reactor 359 # Link ImageDef to ImageDefReactor: 360 self._image_def.append_reactor_handle(reactor_handle) 361 362 @property 363 def image_def(self) -> 'ImageDef': 364 """ Returns the associated IMAGEDEF entity, see :class:`ImageDef`.""" 365 return self._image_def 366 367 @image_def.setter 368 def image_def(self, image_def: 'ImageDef') -> None: 369 if image_def and image_def.is_alive: 370 self.dxf.image_def_handle = image_def.dxf.handle 371 self._image_def = image_def 372 else: 373 self.dxf.discard('image_def_handle') 374 self._image_def = None 375 376 def destroy(self) -> None: 377 if not self.is_alive: 378 return 379 380 reactor = self._image_def_reactor 381 if reactor and reactor.is_alive: 382 image_def = self.image_def 383 if image_def and image_def.is_alive: 384 image_def.discard_reactor_handle(reactor.dxf.handle) 385 reactor.destroy() 386 super().destroy() 387 388 def audit(self, auditor: 'Auditor') -> None: 389 super().audit(auditor) 390 391 392# DXF reference error: Subclass marker (AcDbRasterImage) 393acdb_wipeout = DefSubclass('AcDbWipeout', dict(acdb_image.attribs)) 394acdb_wipeout_group_codes = group_code_mapping(acdb_wipeout) 395 396 397@register_entity 398class Wipeout(ImageBase): 399 """ DXF WIPEOUT entity """ 400 DXFTYPE = 'WIPEOUT' 401 DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_wipeout) 402 DEFAULT_ATTRIBS = { 403 'layer': '0', 404 'flags': 7, 405 'clipping': 1, 406 'brightness': 50, 407 'contrast': 50, 408 'fade': 0, 409 'image_size': (1, 1), 410 'image_def_handle': '0', # has no ImageDef() 411 'image_def_reactor_handle': '0', # has no ImageDefReactor() 412 'clip_mode': 0 413 } 414 _CLS_GROUP_CODES = acdb_wipeout_group_codes 415 _SUBCLASS_NAME = acdb_wipeout.name 416 417 def set_masking_area(self, vertices: Iterable['Vertex']) -> None: 418 """ Set a new masking area, the area is placed in the layout xy-plane. 419 """ 420 self.update_dxf_attribs(self.DEFAULT_ATTRIBS) 421 vertices = Vec2.list(vertices) 422 bounds = BoundingBox2d(vertices) 423 x_size, y_size = bounds.size 424 425 dxf = self.dxf 426 dxf.insert = Vec3(bounds.extmin) 427 dxf.u_pixel = Vec3(x_size, 0, 0) 428 dxf.v_pixel = Vec3(0, y_size, 0) 429 430 def boundary_path(): 431 extmin = bounds.extmin 432 for vertex in vertices: 433 v = (vertex - extmin) 434 yield Vec2(v.x / x_size - 0.5, 0.5 - v.y / y_size) 435 436 self.set_boundary_path(boundary_path()) 437 438 def _reset_handles(self): 439 self.dxf.image_def_reactor_handle = '0' 440 self.dxf.image_def_handle = '0' 441 442 def audit(self, auditor: 'Auditor') -> None: 443 self._reset_handles() 444 super().audit(auditor) 445 446 def export_entity(self, tagwriter: 'TagWriter') -> None: 447 """ Export entity specific data as DXF tags. """ 448 self._reset_handles() 449 super().export_entity(tagwriter) 450 451 452acdb_image_def = DefSubclass('AcDbRasterImageDef', { 453 'class_version': DXFAttr(90, default=0), 454 455 # File name of image: 456 'filename': DXFAttr(1), 457 458 # Image size in pixels: 459 'image_size': DXFAttr(10, xtype=XType.point2d), 460 461 # Default size of one pixel in AutoCAD units: 462 'pixel_size': DXFAttr(11, xtype=XType.point2d, default=(.01, .01)), 463 464 'loaded': DXFAttr(280, default=1), 465 466 # Resolution units - this enums differ from the usual drawing units, 467 # units.py, same as for RasterVariables.dxf.units, but only these 3 values 468 # are valid - confirmed by ODA Specs 20.4.81 IMAGEDEF: 469 # 0 = No units 470 # 2 = Centimeters 471 # 5 = Inch 472 'resolution_units': DXFAttr( 473 281, default=0, 474 validator=validator.is_one_of({0, 2, 5}), 475 fixer=RETURN_DEFAULT, 476 ), 477 478}) 479acdb_image_def_group_codes = group_code_mapping(acdb_image_def) 480 481 482@register_entity 483class ImageDef(DXFObject): 484 """ DXF IMAGEDEF entity """ 485 DXFTYPE = 'IMAGEDEF' 486 DXFATTRIBS = DXFAttributes(base_class, acdb_image_def) 487 MIN_DXF_VERSION_FOR_EXPORT = DXF2000 488 489 def load_dxf_attribs( 490 self, processor: SubclassProcessor = None) -> 'DXFNamespace': 491 dxf = super().load_dxf_attribs(processor) 492 if processor: 493 processor.fast_load_dxfattribs(dxf, acdb_image_def_group_codes, 1) 494 return dxf 495 496 def export_entity(self, tagwriter: 'TagWriter') -> None: 497 """ Export entity specific data as DXF tags. """ 498 super().export_entity(tagwriter) 499 tagwriter.write_tag2(SUBCLASS_MARKER, acdb_image_def.name) 500 self.dxf.export_dxf_attribs(tagwriter, [ 501 'class_version', 'filename', 'image_size', 'pixel_size', 'loaded', 502 'resolution_units', 503 ]) 504 505 506acdb_image_def_reactor = DefSubclass('AcDbRasterImageDefReactor', { 507 'class_version': DXFAttr(90, default=2), 508 509 # Handle to image: 510 'image_handle': DXFAttr(330), 511}) 512acdb_image_def_reactor_group_codes = group_code_mapping(acdb_image_def_reactor) 513 514 515@register_entity 516class ImageDefReactor(DXFObject): 517 """ DXF IMAGEDEF_REACTOR entity """ 518 DXFTYPE = 'IMAGEDEF_REACTOR' 519 DXFATTRIBS = DXFAttributes(base_class, acdb_image_def_reactor) 520 MIN_DXF_VERSION_FOR_EXPORT = DXF2000 521 522 def load_dxf_attribs( 523 self, processor: SubclassProcessor = None) -> 'DXFNamespace': 524 dxf = super().load_dxf_attribs(processor) 525 if processor: 526 processor.fast_load_dxfattribs( 527 dxf, acdb_image_def_reactor_group_codes, 1) 528 return dxf 529 530 def export_entity(self, tagwriter: 'TagWriter') -> None: 531 """ Export entity specific data as DXF tags. """ 532 super().export_entity(tagwriter) 533 tagwriter.write_tag2(SUBCLASS_MARKER, acdb_image_def_reactor.name) 534 tagwriter.write_tag2(90, self.dxf.class_version) 535 tagwriter.write_tag2(330, self.dxf.image_handle) 536 537 538acdb_raster_variables = DefSubclass('AcDbRasterVariables', { 539 'class_version': DXFAttr(90, default=0), 540 541 # Frame: 542 # 0 = no frame 543 # 1 = show frame 544 'frame': DXFAttr( 545 70, default=0, 546 validator=validator.is_integer_bool, 547 fixer=RETURN_DEFAULT, 548 ), 549 # Quality: 550 # 0 = draft 551 # 1 = high 552 'quality': DXFAttr( 553 71, default=1, 554 validator=validator.is_integer_bool, 555 fixer=RETURN_DEFAULT, 556 ), 557 # Units: 558 # 0 = None 559 # 1 = mm 560 # 2 = cm 561 # 3 = m 562 # 4 = km 563 # 5 = in 564 # 6 = ft 565 # 7 = yd 566 # 8 = mi 567 'units': DXFAttr( 568 72, default=3, 569 validator=validator.is_in_integer_range(0, 9), 570 fixer=RETURN_DEFAULT, 571 ), 572 573}) 574acdb_raster_variables_group_codes = group_code_mapping(acdb_raster_variables) 575 576 577@register_entity 578class RasterVariables(DXFObject): 579 """ DXF RASTERVARIABLES entity """ 580 DXFTYPE = 'RASTERVARIABLES' 581 DXFATTRIBS = DXFAttributes(base_class, acdb_raster_variables) 582 MIN_DXF_VERSION_FOR_EXPORT = DXF2000 583 584 def load_dxf_attribs( 585 self, processor: SubclassProcessor = None) -> 'DXFNamespace': 586 dxf = super().load_dxf_attribs(processor) 587 if processor: 588 processor.fast_load_dxfattribs( 589 dxf, acdb_raster_variables_group_codes, 1) 590 return dxf 591 592 def export_entity(self, tagwriter: 'TagWriter') -> None: 593 """ Export entity specific data as DXF tags. """ 594 super().export_entity(tagwriter) 595 tagwriter.write_tag2(SUBCLASS_MARKER, acdb_raster_variables.name) 596 self.dxf.export_dxf_attribs(tagwriter, [ 597 'class_version', 'frame', 'quality', 'units', 598 ]) 599 600 601acdb_wipeout_variables = DefSubclass('AcDbWipeoutVariables', { 602 # Display-image-frame flag: 603 # 0 = No frame 604 # 1 = Display frame 605 'frame': DXFAttr( 606 70, default=0, 607 validator=validator.is_integer_bool, 608 fixer=RETURN_DEFAULT, 609 ), 610}) 611acdb_wipeout_variables_group_codes = group_code_mapping(acdb_wipeout_variables) 612 613 614@register_entity 615class WipeoutVariables(DXFObject): 616 """ DXF WIPEOUTVARIABLES entity """ 617 DXFTYPE = 'WIPEOUTVARIABLES' 618 DXFATTRIBS = DXFAttributes(base_class, acdb_wipeout_variables) 619 MIN_DXF_VERSION_FOR_EXPORT = DXF2000 620 621 def load_dxf_attribs( 622 self, processor: SubclassProcessor = None) -> 'DXFNamespace': 623 dxf = super().load_dxf_attribs(processor) 624 if processor: 625 processor.fast_load_dxfattribs( 626 dxf, acdb_wipeout_variables_group_codes, 1) 627 return dxf 628 629 def export_entity(self, tagwriter: 'TagWriter') -> None: 630 """ Export entity specific data as DXF tags. """ 631 super().export_entity(tagwriter) 632 tagwriter.write_tag2(SUBCLASS_MARKER, acdb_wipeout_variables.name) 633 self.dxf.export_dxf_attribs(tagwriter, 'frame') 634