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