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