1# Copyright (c) 2020, Matthew Broadway
2# License: MIT License
3import math
4from typing import Optional, Iterable, Dict, Sequence, Union
5import warnings
6from collections import defaultdict
7from functools import lru_cache
8from PyQt5 import QtCore as qc, QtGui as qg, QtWidgets as qw
9
10from ezdxf.addons.drawing.backend import Backend, prepare_string_for_rendering
11from ezdxf.tools.fonts import FontMeasurements
12from ezdxf.addons.drawing.type_hints import Color
13from ezdxf.addons.drawing.properties import Properties
14from ezdxf.addons.drawing.line_renderer import AbstractLineRenderer
15from ezdxf.tools import fonts
16from ezdxf.math import Vec3, Matrix44
17from ezdxf.path import Path, Command
18from ezdxf.render.linetypes import LineTypeRenderer as EzdxfLineTypeRenderer
19from ezdxf.tools.pattern import PatternAnalyser
20
21
22class _Point(qw.QAbstractGraphicsShapeItem):
23    """ A dimensionless point which is drawn 'cosmetically' (scale depends on
24    view)
25    """
26
27    def __init__(self, x: float, y: float, brush: qg.QBrush):
28        super().__init__()
29        self.pos = qc.QPointF(x, y)
30        self.radius = 1.0
31        self.setPen(qg.QPen(qc.Qt.NoPen))
32        self.setBrush(brush)
33
34    def paint(self, painter: qg.QPainter, option: qw.QStyleOptionGraphicsItem,
35              widget: Optional[qw.QWidget] = None) -> None:
36        view_scale = _get_x_scale(painter.transform())
37        radius = self.radius / view_scale
38        painter.setBrush(self.brush())
39        painter.setPen(qc.Qt.NoPen)
40        painter.drawEllipse(self.pos, radius, radius)
41
42    def boundingRect(self) -> qc.QRectF:
43        return qc.QRectF(self.pos, qc.QSizeF(1, 1))
44
45
46# The key used to store the dxf entity corresponding to each graphics element
47CorrespondingDXFEntity = qc.Qt.UserRole + 0
48CorrespondingDXFParentStack = qc.Qt.UserRole + 1
49
50PYQT_DEFAULT_PARAMS = {
51    # For my taste without scaling the default line width looks to thin:
52    'lineweight_scaling': 2.0,
53}
54
55
56def get_params(params: Optional[Dict]) -> Dict:
57    default_params = dict(PYQT_DEFAULT_PARAMS)
58    default_params.update(params or {})
59    return default_params
60
61
62class PyQtBackend(Backend):
63    def __init__(self,
64                 scene: Optional[qw.QGraphicsScene] = None,
65                 point_radius=None,  # deprecated
66                 *,
67                 use_text_cache: bool = True,
68                 debug_draw_rect: bool = False,
69                 params: Dict = None):
70        super().__init__(get_params(params))
71        if point_radius is not None:
72            self.point_size = point_radius * 2.0
73            warnings.warn(
74                'The "point_radius" argument is deprecated use the params  dict '
75                'to pass arguments to the PyQtBackend, '
76                'will be removed in v0.16.', DeprecationWarning)
77
78        self._scene = scene
79        self._color_cache = {}
80        self._pattern_cache = {}
81        self._no_line = qg.QPen(qc.Qt.NoPen)
82        self._no_fill = qg.QBrush(qc.Qt.NoBrush)
83        self._text_renderer = TextRenderer(qg.QFont(), use_text_cache)
84        if self.linetype_renderer == "ezdxf":
85            self._line_renderer = EzdxfLineRenderer(self)
86        else:
87            self._line_renderer = InternalLineRenderer(self)
88        self._debug_draw_rect = debug_draw_rect
89
90    def set_scene(self, scene: qw.QGraphicsScene):
91        self._scene = scene
92
93    def clear_text_cache(self):
94        self._text_renderer.clear_cache()
95
96    def _get_color(self, color: Color) -> qg.QColor:
97        qt_color = self._color_cache.get(color, None)
98        if qt_color is None:
99            if len(color) == 7:
100                qt_color = qg.QColor(color)  # '#RRGGBB'
101            elif len(color) == 9:
102                rgb = color[1:7]
103                alpha = color[7:9]
104                qt_color = qg.QColor(f'#{alpha}{rgb}')  # '#AARRGGBB'
105            else:
106                raise TypeError(color)
107
108            self._color_cache[color] = qt_color
109        return qt_color
110
111    def _get_pen(self, properties: Properties) -> qg.QPen:
112        """ Returns a cosmetic pen with applied lineweight but without line type
113        support.
114        """
115        px = properties.lineweight / 0.3527 * self.lineweight_scaling
116        pen = qg.QPen(self._get_color(properties.color), px)
117        # Use constant width in pixel:
118        pen.setCosmetic(True)
119        pen.setJoinStyle(qc.Qt.RoundJoin)
120        return pen
121
122    def _get_brush(self, properties: Properties) -> qg.QBrush:
123        filling = properties.filling
124        if filling:
125            qt_pattern = qc.Qt.SolidPattern
126            if filling.type == filling.PATTERN:
127                if self.hatch_pattern == 1:
128                    # Default pattern scaling is not supported by PyQt:
129                    key = (filling.name, filling.angle)
130                    qt_pattern = self._pattern_cache.get(key)
131                    if qt_pattern is None:
132                        qt_pattern = self._get_qt_pattern(filling.pattern)
133                        self._pattern_cache[key] = qt_pattern
134                elif self.hatch_pattern == 0:
135                    return self._no_fill
136            return qg.QBrush(
137                self._get_color(properties.color),
138                qt_pattern
139            )
140        else:
141            return self._no_fill
142
143    @staticmethod
144    def _get_qt_pattern(pattern) -> int:
145        pattern = PatternAnalyser(pattern)
146        # knowledge of dark or light background would by handy:
147        qt_pattern = qc.Qt.Dense4Pattern
148        if pattern.all_angles(0):
149            qt_pattern = qc.Qt.HorPattern
150        elif pattern.all_angles(90):
151            qt_pattern = qc.Qt.VerPattern
152        elif pattern.has_angle(0) and pattern.has_angle(90):
153            qt_pattern = qc.Qt.CrossPattern
154        if pattern.all_angles(45):
155            qt_pattern = qc.Qt.BDiagPattern
156        elif pattern.all_angles(135):
157            qt_pattern = qc.Qt.FDiagPattern
158        elif pattern.has_angle(45) and pattern.has_angle(135):
159            qt_pattern = qc.Qt.DiagCrossPattern
160        return qt_pattern
161
162    def _set_item_data(self, item: qw.QGraphicsItem) -> None:
163        parent_stack = tuple(e for e, props in self.entity_stack[:-1])
164        current_entity = self.current_entity
165        if isinstance(item, list):
166            for item_ in item:
167                item_.setData(CorrespondingDXFEntity, current_entity)
168                item_.setData(CorrespondingDXFParentStack, parent_stack)
169        else:
170            item.setData(CorrespondingDXFEntity, current_entity)
171            item.setData(CorrespondingDXFParentStack, parent_stack)
172
173    def set_background(self, color: Color):
174        self._scene.setBackgroundBrush(qg.QBrush(self._get_color(color)))
175
176    def draw_point(self, pos: Vec3, properties: Properties) -> None:
177        """ Draw a real dimensionless point. """
178        brush = qg.QBrush(self._get_color(properties.color), qc.Qt.SolidPattern)
179        item = _Point(pos.x, pos.y, brush)
180        self._set_item_data(item)
181        self._scene.addItem(item)
182
183    def draw_line(self, start: Vec3, end: Vec3,
184                  properties: Properties) -> None:
185        # PyQt draws a long line for a zero-length line:
186        if start.isclose(end):
187            self.draw_point(start, properties)
188        else:
189            item = self._line_renderer.draw_line(start, end, properties)
190            self._set_item_data(item)
191
192    def draw_path(self, path: Path, properties: Properties) -> None:
193        item = self._line_renderer.draw_path(path, properties)
194        self._set_item_data(item)
195
196    def draw_filled_paths(self, paths: Sequence[Path], holes: Sequence[Path],
197                          properties: Properties) -> None:
198        qt_path = qg.QPainterPath()
199        for path in paths:
200            _extend_qt_path(qt_path, path.counter_clockwise())
201        for path in holes:
202            _extend_qt_path(qt_path, path.clockwise())
203        item = _CosmeticPath(qt_path)
204        item.setPen(self._get_pen(properties))
205        item.setBrush(self._get_brush(properties))
206        self._scene.addItem(item)
207        self._set_item_data(item)
208
209    def draw_filled_polygon(self, points: Iterable[Vec3],
210                            properties: Properties) -> None:
211        brush = self._get_brush(properties)
212        polygon = qg.QPolygonF()
213        for p in points:
214            polygon.append(qc.QPointF(p.x, p.y))
215        item = _CosmeticPolygon(polygon)
216        item.setPen(self._no_line)
217        item.setBrush(brush)
218        self._scene.addItem(item)
219        self._set_item_data(item)
220
221    def draw_text(self, text: str, transform: Matrix44, properties: Properties,
222                  cap_height: float) -> None:
223        if not text.strip():
224            return  # no point rendering empty strings
225        text = prepare_string_for_rendering(text, self.current_entity.dxftype())
226        qfont = self.get_qfont(properties.font)
227        scale = self._text_renderer.get_scale(cap_height, qfont)
228        transform = Matrix44.scale(scale, -scale, 0) @ transform
229
230        path = self._text_renderer.get_text_path(text, qfont)
231        path = _matrix_to_qtransform(transform).map(path)
232        item = self._scene.addPath(path, self._no_line,
233                                   self._get_color(properties.color))
234        self._set_item_data(item)
235
236    @lru_cache(maxsize=256)  # fonts.Font is a named tuple
237    def get_qfont(self, font: fonts.FontFace) -> qg.QFont:
238        qfont = self._text_renderer.default_font
239        if font:
240            family = font.family
241            italic = "italic" in font.style.lower()
242            weight = _map_weight(font.weight)
243            qfont = qg.QFont(family, weight=weight, italic=italic)
244            # TODO: setting the stretch value makes results worse!
245            # qfont.setStretch(_map_stretch(font.stretch))
246        return qfont
247
248    def get_font_measurements(self, cap_height: float,
249                              font: fonts.FontFace = None) -> FontMeasurements:
250        qfont = self.get_qfont(font)
251        return self._text_renderer.get_font_measurements(
252            qfont).scale_from_baseline(desired_cap_height=cap_height)
253
254    def get_text_line_width(self, text: str, cap_height: float,
255                            font: fonts.FontFace = None) -> float:
256        if not text.strip():
257            return 0
258
259        dxftype = self.current_entity.dxftype() if self.current_entity else 'TEXT'
260        text = prepare_string_for_rendering(text, dxftype)
261        qfont = self.get_qfont(font)
262        scale = self._text_renderer.get_scale(cap_height, qfont)
263        return self._text_renderer.get_text_rect(text, qfont).right() * scale
264
265    def clear(self) -> None:
266        self._scene.clear()
267
268    def finalize(self) -> None:
269        super().finalize()
270        self._scene.setSceneRect(self._scene.itemsBoundingRect())
271        if self._debug_draw_rect:
272            properties = Properties()
273            properties.color = '#000000'
274            self._scene.addRect(
275                self._scene.sceneRect(),
276                self._get_pen(properties),
277                self._no_fill
278            )
279
280
281class _CosmeticPath(qw.QGraphicsPathItem):
282    def paint(self, painter: qg.QPainter, option: qw.QStyleOptionGraphicsItem,
283              widget: Optional[qw.QWidget] = None) -> None:
284        _set_cosmetic_brush(self, painter)
285        super().paint(painter, option, widget)
286
287
288class _CosmeticPolygon(qw.QGraphicsPolygonItem):
289    def paint(self, painter: qg.QPainter, option: qw.QStyleOptionGraphicsItem,
290              widget: Optional[qw.QWidget] = None) -> None:
291        _set_cosmetic_brush(self, painter)
292        super().paint(painter, option, widget)
293
294
295def _set_cosmetic_brush(item: qw.QGraphicsItem, painter: qg.QPainter) -> None:
296    """ like a cosmetic pen, this sets the brush pattern to appear the same independent of the view """
297    brush = item.brush()
298    # scale by -1 in y because the view is always mirrored in y and undoing the view transformation entirely would make
299    # the hatch mirrored w.r.t the view
300    brush.setTransform(painter.transform().inverted()[0].scale(1, -1))
301    item.setBrush(brush)
302
303
304def _extend_qt_path(qt_path: qg.QPainterPath, path: Path) -> None:
305    start = path.start
306    qt_path.moveTo(start.x, start.y)
307    for cmd in path:
308        if cmd.type == Command.LINE_TO:
309            end = cmd.end
310            qt_path.lineTo(end.x, end.y)
311        elif cmd.type == Command.CURVE4_TO:
312            end = cmd.end
313            ctrl1 = cmd.ctrl1
314            ctrl2 = cmd.ctrl2
315            qt_path.cubicTo(
316                ctrl1.x, ctrl1.y, ctrl2.x, ctrl2.y, end.x, end.y
317            )
318        else:
319            raise ValueError(f'Unknown path command: {cmd.type}')
320
321
322# https://doc.qt.io/qt-5/qfont.html#Weight-enum
323# QFont::Thin	0	0
324# QFont::ExtraLight	12	12
325# QFont::Light	25	25
326# QFont::Normal	50	50
327# QFont::Medium	57	57
328# QFont::DemiBold	63	63
329# QFont::Bold	75	75
330# QFont::ExtraBold	81	81
331# QFont::Black	87	87
332def _map_weight(weight: Union[str, int]) -> int:
333    if isinstance(weight, str):
334        weight = fonts.weight_name_to_value(weight)
335    value = int((weight / 10) + 10)  # normal: 400 -> 50
336    return min(max(0, value), 99)
337
338
339# https://doc.qt.io/qt-5/qfont.html#Stretch-enum
340StretchMapping = {
341    "ultracondensed": 50,
342    "extracondensed": 62,
343    "condensed": 75,
344    "semicondensed": 87,
345    "unstretched": 100,
346    "semiexpanded": 112,
347    "expanded": 125,
348    "extraexpanded": 150,
349    "ultraexpanded": 200,
350}
351
352
353def _map_stretch(stretch: str) -> int:
354    return StretchMapping.get(stretch.lower(), 100)
355
356
357def _get_x_scale(t: qg.QTransform) -> float:
358    return math.sqrt(t.m11() * t.m11() + t.m21() * t.m21())
359
360
361def _matrix_to_qtransform(matrix: Matrix44) -> qg.QTransform:
362    """ Qt also uses row-vectors so the translation elements are placed in the
363    bottom row.
364
365    This is only a simple conversion which assumes that although the
366    transformation is 4x4,it does not involve the z axis.
367
368    A more correct transformation could be implemented like so:
369    https://stackoverflow.com/questions/10629737/convert-3d-4x4-rotation-matrix-into-2d
370    """
371    return qg.QTransform(*matrix.get_2d_transformation())
372
373
374class TextRenderer:
375    def __init__(self, font: qg.QFont, use_cache: bool):
376        self._default_font = font
377        self._use_cache = use_cache
378
379        # Each font has its own text path cache
380        # key is QFont.key()
381        self._text_path_cache: Dict[
382            str, Dict[str, qg.QPainterPath]] = defaultdict(dict)
383
384        # Each font has its own font measurements cache
385        # key is QFont.key()
386        self._font_measurement_cache: Dict[str, FontMeasurements] = {}
387
388    @property
389    def default_font(self) -> qg.QFont:
390        return self._default_font
391
392    def clear_cache(self):
393        self._text_path_cache.clear()
394
395    def get_scale(self, desired_cap_height: float, font: qg.QFont) -> float:
396        measurements = self.get_font_measurements(font)
397        return desired_cap_height / measurements.cap_height
398
399    def get_font_measurements(self, font: qg.QFont) -> FontMeasurements:
400        # None is the default font.
401        key = font.key() if font is not None else None
402        measurements = self._font_measurement_cache.get(key)
403        if measurements is None:
404            upper_x = self.get_text_rect('X', font)
405            lower_x = self.get_text_rect('x', font)
406            lower_p = self.get_text_rect('p', font)
407            baseline = lower_x.bottom()
408            measurements = FontMeasurements(
409                baseline=baseline,
410                cap_height=baseline - upper_x.top(),
411                x_height=baseline - lower_x.top(),
412                descender_height=lower_p.bottom() - baseline,
413            )
414            self._font_measurement_cache[key] = measurements
415        return measurements
416
417    def get_text_path(self, text: str, font: qg.QFont) -> qg.QPainterPath:
418        # None is the default font
419        key = font.key() if font is not None else None
420        cache = self._text_path_cache[key]  # defaultdict(dict)
421        path = cache.get(text, None)
422        if path is None:
423            if font is None:
424                font = self._default_font
425            path = qg.QPainterPath()
426            path.addText(0, 0, font, text)
427            if self._use_cache:
428                cache[text] = path
429        return path
430
431    def get_text_rect(self, text: str, font: qg.QFont) -> qc.QRectF:
432        # no point caching the bounding rect calculation, it is very cheap
433        return self.get_text_path(text, font).boundingRect()
434
435
436# noinspection PyUnresolvedReferences,PyProtectedMember
437class PyQtLineRenderer(AbstractLineRenderer):
438
439    @property
440    def scene(self) -> qw.QGraphicsScene:
441        return self._backend._scene
442
443    @property
444    def no_fill(self):
445        return self._backend._no_fill
446
447    def get_color(self, color: Color) -> qg.QColor:
448        return self._backend._get_color(color)
449
450    def get_pen(self, properties: Properties) -> qg.QPen:
451        return self._backend._get_pen(properties)
452
453
454# Just guessing here: this values assume a cosmetic pen!
455ISO_LIN_PATTERN_FACTOR = 15
456ANSI_LIN_PATTERN_FACTOR = ISO_LIN_PATTERN_FACTOR * 2.54
457
458
459class InternalLineRenderer(PyQtLineRenderer):
460    """ PyQt internal linetype rendering """
461
462    @property
463    def measurement_scale(self) -> float:
464        return ISO_LIN_PATTERN_FACTOR if self.measurement \
465            else ISO_LIN_PATTERN_FACTOR
466
467    def get_pen(self, properties: Properties) -> qg.QPen:
468        pen = super().get_pen(properties)
469        if len(properties.linetype_pattern) > 1 and self.linetype_scaling != 0:
470            # The dash pattern is specified in units of the pens width; e.g. a
471            # dash of length 5 in width 10 is 50 pixels long.
472            pattern = self.pattern(properties)
473            if len(pattern):
474                pen.setDashPattern(pattern)
475        return pen
476
477    def draw_line(self, start: Vec3, end: Vec3,
478                  properties: Properties, z=0):
479        return self.scene.addLine(
480            start.x, start.y, end.x, end.y,
481            self.get_pen(properties)
482        )
483
484    def draw_path(self, path: Path, properties: Properties, z=0):
485        qt_path = qg.QPainterPath()
486        _extend_qt_path(qt_path, path)
487        return self.scene.addPath(
488            qt_path,
489            self.get_pen(properties),
490            self.no_fill,
491        )
492
493
494class EzdxfLineRenderer(PyQtLineRenderer):
495    """ Replicate AutoCAD linetype rendering oriented on drawing units and
496    various ltscale factors. This rendering method break lines into small
497    segments which causes a longer rendering time!
498    """
499
500    def draw_line(self, start: Vec3, end: Vec3,
501                  properties: Properties, z=0):
502        pattern = self.pattern(properties)
503        render_linetypes = bool(self.linetype_scaling)
504        pen = self.get_pen(properties)
505        if len(pattern) < 2 or not render_linetypes:
506            return self.scene.addLine(start.x, start.y, end.x, end.y, pen)
507        else:
508            add_line = self.scene.addLine
509            renderer = EzdxfLineTypeRenderer(pattern)
510            return [
511                add_line(s.x, s.y, e.x, e.y, pen)
512                for s, e in renderer.line_segment(start, end)
513                # PyQt has problems with very short lines:
514                if not s.isclose(e)
515            ]
516
517    def draw_path(self, path, properties: Properties, z=0):
518        pattern = self.pattern(properties)
519        pen = self.get_pen(properties)
520        render_linetypes = bool(self.linetype_scaling)
521        if len(pattern) < 2 or not render_linetypes:
522            qt_path = qg.QPainterPath()
523            _extend_qt_path(qt_path, path)
524            return self.scene.addPath(qt_path, pen, self.no_fill)
525        else:
526            add_line = self.scene.addLine
527            renderer = EzdxfLineTypeRenderer(pattern)
528            segments = renderer.line_segments(path.flattening(
529                self.max_flattening_distance, segments=16))
530            return [
531                add_line(s.x, s.y, e.x, e.y, pen)
532                for s, e in segments
533                # PyQt has problems with very short lines:
534                if not s.isclose(e)
535            ]
536