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