1# Copyright (c) 2020-2021, Matthew Broadway
2# License: MIT License
3import math
4from typing import Iterable, cast, Union, List, Dict, Callable
5from ezdxf.lldxf import const
6from ezdxf.addons.drawing.backend import Backend
7from ezdxf.addons.drawing.properties import (
8    RenderContext, VIEWPORT_COLOR, Properties, set_color_alpha, Filling,
9)
10from ezdxf.addons.drawing.text import simplified_text_chunks
11from ezdxf.entities import (
12    DXFGraphic, Insert, MText, Polyline, LWPolyline, Spline, Hatch, Attrib,
13    Text, Polyface, Wipeout, AttDef, Solid, Face3d
14)
15from ezdxf.entities.dxfentity import DXFTagStorage, DXFEntity
16from ezdxf.layouts import Layout
17from ezdxf.math import Vec3, Z_AXIS
18from ezdxf.path import (
19    Path, make_path, from_hatch_boundary_path, fast_bbox_detection,
20    winding_deconstruction, from_vertices,
21)
22from ezdxf.render import MeshBuilder, TraceBuilder
23from ezdxf import reorder
24from ezdxf.proxygraphic import ProxyGraphic
25
26__all__ = ['Frontend']
27NEG_Z_AXIS = -Z_AXIS
28INFINITE_LINE_LENGTH = 25
29DEFAULT_PDSIZE = 1
30
31IGNORE_PROXY_GRAPHICS = 0
32USE_PROXY_GRAPHICS = 1
33PREFER_PROXY_GRAPHICS = 2
34
35
36class Frontend:
37    """ Drawing frontend, responsible for decomposing entities into graphic
38    primitives and resolving entity properties.
39
40    Args:
41        ctx: actual render context of a DXF document
42        out: backend
43
44    """
45
46    def __init__(self, ctx: RenderContext, out: Backend,
47                 proxy_graphics: int = USE_PROXY_GRAPHICS):
48        # RenderContext contains all information to resolve resources for a
49        # specific DXF document.
50        self.ctx = ctx
51
52        # DrawingBackend is the interface to the render engine
53        self.out = out
54
55        # To get proxy graphics support proxy graphics have to be loaded:
56        # Set the global option ezdxf.options.load_proxy_graphics to True.
57        # How to handle proxy graphics:
58        # 0 = ignore proxy graphics
59        # 1 = use proxy graphics if no rendering support by ezdxf exist
60        # 2 = prefer proxy graphics over ezdxf rendering
61        self.proxy_graphics = proxy_graphics
62
63        # Transfer render context info to backend:
64        ctx.update_backend_configuration(out)
65
66        # Parents entities of current entity/sub-entity
67        self.parent_stack: List[DXFGraphic] = []
68
69        # Approximate a full circle by `n` segments, arcs have proportional
70        # less segments
71        self.circle_approximation_count = 128
72
73        # The sagitta (also known as the versine) is a line segment drawn
74        # perpendicular to a chord, between the midpoint of that chord and the
75        # arc of the circle. https://en.wikipedia.org/wiki/Circle not used yet!
76        # Could be used for all curves CIRCLE, ARC, ELLIPSE and SPLINE
77        # self.approximation_max_sagitta = 0.01  # for drawing unit = 1m, max
78        # sagitta = 1cm
79
80        # set to None to disable nested polygon detection:
81        self.nested_polygon_detection = fast_bbox_detection
82
83        self._dispatch = self._build_dispatch_table()
84
85    def _build_dispatch_table(self) -> Dict[
86        str, Callable[[DXFGraphic, Properties], None]]:
87        dispatch_table = {
88            'POINT': self.draw_point_entity,
89            'HATCH': self.draw_hatch_entity,
90            'MESH': self.draw_mesh_entity,
91            'VIEWPORT': self.draw_viewport_entity,
92            'WIPEOUT': self.draw_wipeout_entity,
93            'MTEXT': self.draw_mtext_entity,
94        }
95        for dxftype in ('LINE', 'XLINE', 'RAY'):
96            dispatch_table[dxftype] = self.draw_line_entity
97        for dxftype in ('TEXT', 'ATTRIB', 'ATTDEF'):
98            dispatch_table[dxftype] = self.draw_text_entity
99        for dxftype in ('CIRCLE', 'ARC', 'ELLIPSE', 'SPLINE'):
100            dispatch_table[dxftype] = self.draw_curve_entity
101        for dxftype in ('3DFACE', 'SOLID', 'TRACE'):
102            dispatch_table[dxftype] = self.draw_solid_entity
103        for dxftype in ('POLYLINE', 'LWPOLYLINE'):
104            dispatch_table[dxftype] = self.draw_polyline_entity
105
106        # These types have a virtual_entities() method, which returns
107        # the content of the associated block or anonymous block
108        for dxftype in ['INSERT', 'DIMENSION', 'ARC_DIMENSION',
109                        'LARGE_RADIAL_DIMENSION', 'LEADER',
110                        'MLINE', 'ACAD_TABLE']:
111            dispatch_table[dxftype] = self.draw_composite_entity
112
113        return dispatch_table
114
115    def log_message(self, message: str):
116        print(message)
117
118    def skip_entity(self, entity: DXFEntity, msg: str) -> None:
119        self.log_message(f'skipped entity {str(entity)}. Reason: "{msg}"')
120
121    def override_properties(self, entity: DXFGraphic,
122                            properties: Properties) -> None:
123        """ The :meth:`override_properties` filter can change the properties of
124        an entity independent from the DXF attributes.
125
126        This filter has access to the DXF attributes by the `entity` object,
127        the current render context, and the resolved properties by the
128        `properties` object. It is recommended to modify only the `properties`
129        object in this filter.
130        """
131        if entity.dxftype() == 'HATCH':
132            properties.color = set_color_alpha(properties.color, 200)
133
134    def draw_layout(self, layout: 'Layout', finalize: bool = True) -> None:
135        self.parent_stack = []
136        handle_mapping = list(layout.get_redraw_order())
137        if handle_mapping:
138            self.draw_entities(reorder.ascending(layout, handle_mapping))
139        else:
140            self.draw_entities(layout)
141        self.out.set_background(self.ctx.current_layout.background_color)
142        if finalize:
143            self.out.finalize()
144
145    def draw_entities(self, entities: Iterable[DXFGraphic]) -> None:
146        for entity in entities:
147            # Skip unsupported DXF entities - just tag storage to preserve data
148            if isinstance(entity, DXFTagStorage):
149                self.skip_entity(entity, 'Cannot parse DXF entity')
150                continue
151
152            properties = self.ctx.resolve_all(entity)
153            self.override_properties(entity, properties)
154
155            # The content of a block reference does not depend
156            # on the visibility state of the INSERT entity:
157            if properties.is_visible or entity.dxftype() == 'INSERT':
158                self.draw_entity(entity, properties)
159            elif not properties.is_visible:
160                self.skip_entity(entity, 'invisible')
161
162    def draw_entity(self, entity: DXFGraphic, properties: Properties) -> None:
163        """ Draw a single DXF entity.
164
165        Args:
166            entity: DXF Entity
167            properties: resolved entity properties
168
169        """
170        self.out.enter_entity(entity, properties)
171
172        if entity.proxy_graphic and self.proxy_graphics == PREFER_PROXY_GRAPHICS:
173            self.draw_proxy_graphic(entity)
174        else:
175            draw_method = self._dispatch.get(entity.dxftype(), None)
176            if draw_method is not None:
177                draw_method(entity, properties)
178            elif entity.proxy_graphic and self.proxy_graphics == USE_PROXY_GRAPHICS:
179                self.draw_proxy_graphic(entity)
180            else:
181                self.skip_entity(entity, 'Unsupported entity')
182        self.out.exit_entity(entity)
183
184    def draw_line_entity(self, entity: DXFGraphic,
185                         properties: Properties) -> None:
186        d, dxftype = entity.dxf, entity.dxftype()
187        if dxftype == 'LINE':
188            self.out.draw_line(d.start, d.end, properties)
189
190        elif dxftype in ('XLINE', 'RAY'):
191            start = d.start
192            delta = d.unit_vector * INFINITE_LINE_LENGTH
193            if dxftype == 'XLINE':
194                self.out.draw_line(start - delta / 2, start + delta / 2,
195                                   properties)
196            elif dxftype == 'RAY':
197                self.out.draw_line(start, start + delta, properties)
198        else:
199            raise TypeError(dxftype)
200
201    def draw_text_entity(self, entity: DXFGraphic,
202                         properties: Properties) -> None:
203        if is_spatial_text(Vec3(entity.dxf.extrusion)):
204            self.draw_text_entity_3d(entity, properties)
205        else:
206            self.draw_text_entity_2d(entity, properties)
207
208    def draw_text_entity_2d(self, entity: DXFGraphic,
209                            properties: Properties) -> None:
210        d, dxftype = entity.dxf, entity.dxftype()
211        if dxftype in ('TEXT', 'ATTRIB', 'ATTDEF'):
212            entity = cast(Union[Text, Attrib, AttDef], entity)
213            for line, transform, cap_height in simplified_text_chunks(
214                    entity, self.out, font=properties.font):
215                self.out.draw_text(line, transform, properties, cap_height)
216        else:
217            raise TypeError(dxftype)
218
219    def draw_text_entity_3d(self, entity: DXFGraphic,
220                            properties: Properties) -> None:
221        self.skip_entity(entity, '3D text not supported')
222
223    def draw_mtext_entity(self, mtext: 'MText',
224                          properties: Properties) -> None:
225        if is_spatial_text(Vec3(mtext.dxf.extrusion)):
226            self.skip_entity(mtext, '3D MTEXT not supported')
227            return
228        if mtext.has_columns:
229            columns = mtext.columns
230            if len(columns.linked_columns):
231                has_linked_content = any(c.text for c in columns.linked_columns)
232                if has_linked_content:
233                    # Column content is spread across multiple MTEXT entities.
234                    # For now we trust the DXF creator that each MTEXT entity
235                    # has exact the required column content.
236                    # This is not granted and AutoCAD/BricsCAD do the column
237                    # content distribution always by themself!
238                    self.draw_mtext_column(mtext, properties)
239                    for column in mtext.columns.linked_columns:
240                        self.draw_mtext_column(column, properties)
241                    return
242            self.distribute_mtext_columns_content(mtext, properties)
243        else:
244            self.draw_mtext_column(mtext, properties)
245
246    def distribute_mtext_columns_content(self, mtext: MText,
247                                         properties: Properties):
248        """ Distribute the content of the MTEXT entity across multiple columns
249        """
250        # TODO: complex MTEXT renderer
251        self.draw_mtext_column(mtext, properties)
252
253    def draw_mtext_column(self, mtext: MText,
254                          properties: Properties) -> None:
255        """ Draw the content of a MTEXT entity as a single column. """
256        # TODO: complex MTEXT renderer
257        for line, transform, cap_height in simplified_text_chunks(
258                mtext, self.out, font=properties.font):
259            self.out.draw_text(line, transform, properties, cap_height)
260
261    def draw_curve_entity(self, entity: DXFGraphic,
262                          properties: Properties) -> None:
263        try:
264            path = make_path(entity)
265        except AttributeError:  # API usage error
266            raise TypeError(
267                f"Unsupported DXF type {entity.dxftype()}")
268        self.out.draw_path(path, properties)
269
270    def draw_point_entity(self, entity: DXFGraphic,
271                          properties: Properties) -> None:
272        point = cast('Point', entity)
273        pdmode = self.out.pdmode
274
275        # Defpoints are regular POINT entities located at the "defpoints" layer:
276        if properties.layer.lower() == 'defpoints':
277            if not self.out.show_defpoints:
278                return
279            else:  # Render defpoints as dimensionless points:
280                pdmode = 0
281
282        pdsize = self.out.pdsize
283        if pdsize <= 0:  # relative points size is not supported
284            pdsize = DEFAULT_PDSIZE
285
286        if pdmode == 0:
287            self.out.draw_point(entity.dxf.location, properties)
288        else:
289            for entity in point.virtual_entities(pdsize, pdmode):
290                if entity.dxftype() == 'LINE':
291                    start = Vec3(entity.dxf.start)
292                    end = entity.dxf.end
293                    if start.isclose(end):
294                        self.out.draw_point(start, properties)
295                    else:
296                        self.out.draw_line(start, end, properties)
297                    pass
298                else:  # CIRCLE
299                    self.draw_curve_entity(entity, properties)
300
301    def draw_solid_entity(self, entity: DXFGraphic,
302                          properties: Properties) -> None:
303        assert isinstance(entity, (Solid, Face3d)), \
304            "API error, requires a SOLID, TRACE or 3DFACE entity"
305        dxf, dxftype = entity.dxf, entity.dxftype()
306        points = entity.wcs_vertices()
307        if dxftype == '3DFACE':
308            self.out.draw_path(from_vertices(points, close=True), properties)
309        else:
310            # set solid fill type for SOLID and TRACE
311            properties.filling = Filling()
312            self.out.draw_filled_polygon(points, properties)
313
314    def draw_hatch_entity(self, entity: DXFGraphic,
315                          properties: Properties) -> None:
316        def to_path(p):
317            path = from_hatch_boundary_path(p, ocs, elevation)
318            path.close()
319            return path
320
321        if not self.out.show_hatch:
322            return
323
324        hatch = cast(Hatch, entity)
325        ocs = hatch.ocs()
326        # all OCS coordinates have the same z-axis stored as vector (0, 0, z),
327        # default (0, 0, 0)
328        elevation = entity.dxf.elevation.z
329
330        external_paths = []
331        holes = []
332        paths = hatch.paths.rendering_paths(hatch.dxf.hatch_style)
333        if self.nested_polygon_detection:
334            polygons = self.nested_polygon_detection(map(to_path, paths))
335            external_paths, holes = winding_deconstruction(polygons)
336        else:
337            for p in paths:
338                if p.path_type_flags & const.BOUNDARY_PATH_EXTERNAL:
339                    external_paths.append(to_path(p))
340                else:
341                    holes.append(to_path(p))
342
343        if external_paths:
344            self.out.draw_filled_paths(external_paths, holes, properties)
345        elif holes:
346            # First path is the exterior path, everything else is a hole
347            self.out.draw_filled_paths([holes[0]], holes[1:], properties)
348
349    def draw_wipeout_entity(self, entity: DXFGraphic,
350                            properties: Properties) -> None:
351        wipeout = cast(Wipeout, entity)
352        properties.filling = Filling()
353        properties.color = self.ctx.current_layout.background_color
354        path = wipeout.boundary_path_wcs()
355        self.out.draw_filled_polygon(path, properties)
356
357    def draw_viewport_entity(self, entity: DXFGraphic,
358                             properties: Properties) -> None:
359        assert entity.dxftype() == 'VIEWPORT'
360        # Special VIEWPORT id == 1, this viewport defines the "active viewport"
361        # which is the area currently shown in the layout tab by the CAD
362        # application.
363        # BricsCAD set id to -1 if the viewport is off and 'status' (group
364        # code 68) is not present.
365        if entity.dxf.id < 2 or entity.dxf.status < 1:
366            return
367        dxf = entity.dxf
368        view_vector: Vec3 = dxf.view_direction_vector
369        mag = view_vector.magnitude
370        if math.isclose(mag, 0.0):
371            self.log_message('Warning: viewport with null view vector')
372            return
373        view_vector /= mag
374        if not math.isclose(view_vector.dot(Vec3(0, 0, 1)), 1.0):
375            self.log_message(
376                f'Cannot render viewport with non-perpendicular view direction:'
377                f' {dxf.view_direction_vector}'
378            )
379            return
380
381        cx, cy = dxf.center.x, dxf.center.y
382        dx = dxf.width / 2
383        dy = dxf.height / 2
384        minx, miny = cx - dx, cy - dy
385        maxx, maxy = cx + dx, cy + dy
386        points = [
387            (minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy), (minx, miny)
388        ]
389        props = Properties()
390        props.color = VIEWPORT_COLOR
391        # Set default SOLID filling for VIEWPORT
392        props.filling = Filling()
393        self.out.draw_filled_polygon([Vec3(x, y, 0) for x, y in points],
394                                     props)
395
396    def draw_mesh_entity(self, entity: DXFGraphic,
397                         properties: Properties) -> None:
398        builder = MeshBuilder.from_mesh(entity)
399        self.draw_mesh_builder_entity(builder, properties)
400
401    def draw_mesh_builder_entity(self, builder: MeshBuilder,
402                                 properties: Properties) -> None:
403        for face in builder.faces_as_vertices():
404            self.out.draw_path(
405                from_vertices(face, close=True), properties=properties)
406
407    def draw_polyline_entity(self, entity: DXFGraphic,
408                             properties: Properties) -> None:
409        dxftype = entity.dxftype()
410        if dxftype == 'POLYLINE':
411            e = cast(Polyface, entity)
412            if e.is_polygon_mesh or e.is_poly_face_mesh:
413                # draw 3D mesh or poly-face entity
414                self.draw_mesh_builder_entity(
415                    MeshBuilder.from_polyface(e),
416                    properties,
417                )
418                return
419
420        entity = cast(Union[LWPolyline, Polyline], entity)
421        is_lwpolyline = dxftype == 'LWPOLYLINE'
422
423        if entity.has_width:  # draw banded 2D polyline
424            elevation = 0.0
425            ocs = entity.ocs()
426            transform = ocs.transform
427            if transform:
428                if is_lwpolyline:  # stored as float
429                    elevation = entity.dxf.elevation
430                else:  # stored as vector (0, 0, elevation)
431                    elevation = Vec3(entity.dxf.elevation).z
432
433            trace = TraceBuilder.from_polyline(
434                entity, segments=self.circle_approximation_count // 2
435            )
436            for polygon in trace.polygons():  # polygon is a sequence of Vec2()
437                if transform:
438                    points = ocs.points_to_wcs(
439                        Vec3(v.x, v.y, elevation) for v in polygon
440                    )
441                else:
442                    points = Vec3.generate(polygon)
443                # Set default SOLID filling for LWPOLYLINE
444                properties.filling = Filling()
445                self.out.draw_filled_polygon(points, properties)
446            return
447
448        path = make_path(entity)
449        self.out.draw_path(path, properties)
450
451    def draw_composite_entity(self, entity: DXFGraphic,
452                              properties: Properties) -> None:
453        def set_opaque(entities: Iterable[DXFGraphic]):
454            for child in entities:
455                # todo: defaults to 1.0 (fully transparent)???
456                child.transparency = 0.0
457                yield child
458
459        def draw_insert(insert: Insert):
460            self.draw_entities(insert.attribs)
461            # draw_entities() includes the visibility check:
462            self.draw_entities(insert.virtual_entities(
463                skipped_entity_callback=self.skip_entity)
464            )
465
466        dxftype = entity.dxftype()
467        if dxftype == 'INSERT':
468            entity = cast(Insert, entity)
469            self.ctx.push_state(properties)
470            if entity.mcount > 1:
471                for virtual_insert in entity.multi_insert():
472                    draw_insert(virtual_insert)
473            else:
474                draw_insert(entity)
475            self.ctx.pop_state()
476
477        elif hasattr(entity, 'virtual_entities'):
478            # draw_entities() includes the visibility check:
479            self.draw_entities(set_opaque(entity.virtual_entities()))
480        else:
481            raise TypeError(dxftype)
482
483    def draw_proxy_graphic(self, entity: DXFGraphic) -> None:
484        if entity.proxy_graphic:
485            gfx = ProxyGraphic(entity.proxy_graphic, entity.doc)
486            self.draw_entities(gfx.virtual_entities())
487
488
489def is_spatial_text(extrusion: Vec3) -> bool:
490    # note: the magnitude of the extrusion vector has no effect on text scale
491    return not math.isclose(extrusion.x, 0) or not math.isclose(extrusion.y, 0)
492