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