Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/pyqt.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

315 lines
11 KiB
Python

# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
# mypy: ignore_errors=True
from __future__ import annotations
from typing import Optional, Iterable
import abc
import math
import numpy as np
from ezdxf.addons.xqt import QtCore as qc, QtGui as qg, QtWidgets as qw
from ezdxf.addons.drawing.backend import Backend, BkPath2d, BkPoints2d, ImageData
from ezdxf.addons.drawing.config import Configuration
from ezdxf.addons.drawing.type_hints import Color
from ezdxf.addons.drawing.properties import BackendProperties
from ezdxf.math import Vec2, Matrix44
from ezdxf.npshapes import to_qpainter_path
class _Point(qw.QAbstractGraphicsShapeItem):
"""A dimensionless point which is drawn 'cosmetically' (scale depends on
view)
"""
def __init__(self, x: float, y: float, brush: qg.QBrush):
super().__init__()
self.location = qc.QPointF(x, y)
self.radius = 1.0
self.setPen(qg.QPen(qc.Qt.NoPen))
self.setBrush(brush)
def paint(
self,
painter: qg.QPainter,
option: qw.QStyleOptionGraphicsItem,
widget: Optional[qw.QWidget] = None,
) -> None:
view_scale = _get_x_scale(painter.transform())
radius = self.radius / view_scale
painter.setBrush(self.brush())
painter.setPen(qc.Qt.NoPen)
painter.drawEllipse(self.location, radius, radius)
def boundingRect(self) -> qc.QRectF:
return qc.QRectF(self.location, qc.QSizeF(1, 1))
# The key used to store the dxf entity corresponding to each graphics element
CorrespondingDXFEntity = qc.Qt.UserRole + 0 # type: ignore
CorrespondingDXFParentStack = qc.Qt.UserRole + 1 # type: ignore
class _PyQtBackend(Backend):
"""
Abstract PyQt backend which uses the :mod:`PySide6` package to implement an
interactive viewer. The :mod:`PyQt5` package can be used as fallback if the
:mod:`PySide6` package is not available.
"""
def __init__(self, scene: qw.QGraphicsScene):
super().__init__()
self._scene = scene
self._color_cache: dict[Color, qg.QColor] = {}
self._no_line = qg.QPen(qc.Qt.NoPen)
self._no_fill = qg.QBrush(qc.Qt.NoBrush)
def configure(self, config: Configuration) -> None:
if config.min_lineweight is None:
config = config.with_changes(min_lineweight=0.24)
super().configure(config)
def set_scene(self, scene: qw.QGraphicsScene) -> None:
self._scene = scene
def _add_item(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
self.set_item_data(item, entity_handle)
self._scene.addItem(item)
@abc.abstractmethod
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
...
def _get_color(self, color: Color) -> qg.QColor:
try:
return self._color_cache[color]
except KeyError:
pass
if len(color) == 7:
qt_color = qg.QColor(color) # '#RRGGBB'
elif len(color) == 9:
rgb = color[1:7]
alpha = color[7:9]
qt_color = qg.QColor(f"#{alpha}{rgb}") # '#AARRGGBB'
else:
raise TypeError(color)
self._color_cache[color] = qt_color
return qt_color
def _get_pen(self, properties: BackendProperties) -> qg.QPen:
"""Returns a cosmetic pen with applied lineweight but without line type
support.
"""
px = properties.lineweight / 0.3527 * self.config.lineweight_scaling
pen = qg.QPen(self._get_color(properties.color), px)
# Use constant width in pixel:
pen.setCosmetic(True)
pen.setJoinStyle(qc.Qt.RoundJoin)
return pen
def _get_fill_brush(self, color: Color) -> qg.QBrush:
return qg.QBrush(self._get_color(color), qc.Qt.SolidPattern) # type: ignore
def set_background(self, color: Color):
self._scene.setBackgroundBrush(qg.QBrush(self._get_color(color)))
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
"""Draw a real dimensionless point."""
brush = self._get_fill_brush(properties.color)
item = _Point(pos.x, pos.y, brush)
self._add_item(item, properties.handle)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
# PyQt draws a long line for a zero-length line:
if start.isclose(end):
self.draw_point(start, properties)
else:
item = qw.QGraphicsLineItem(start.x, start.y, end.x, end.y)
item.setPen(self._get_pen(properties))
self._add_item(item, properties.handle)
def draw_solid_lines(
self,
lines: Iterable[tuple[Vec2, Vec2]],
properties: BackendProperties,
):
"""Fast method to draw a bunch of solid lines with the same properties."""
pen = self._get_pen(properties)
add_line = self._add_item
for s, e in lines:
if s.isclose(e):
self.draw_point(s, properties)
else:
item = qw.QGraphicsLineItem(s.x, s.y, e.x, e.y)
item.setPen(pen)
add_line(item, properties.handle)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
if len(path) == 0:
return
item = qw.QGraphicsPathItem(to_qpainter_path([path]))
item.setPen(self._get_pen(properties))
item.setBrush(self._no_fill)
self._add_item(item, properties.handle)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
# Default fill rule is OddEvenFill! Detecting the path orientation is not
# necessary!
_paths = list(paths)
if len(_paths) == 0:
return
item = _CosmeticPath(to_qpainter_path(_paths))
item.setPen(self._get_pen(properties))
item.setBrush(self._get_fill_brush(properties.color))
self._add_item(item, properties.handle)
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
brush = self._get_fill_brush(properties.color)
polygon = qg.QPolygonF()
for p in points.vertices():
polygon.append(qc.QPointF(p.x, p.y))
item = _CosmeticPolygon(polygon)
item.setPen(self._no_line)
item.setBrush(brush)
self._add_item(item, properties.handle)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
image = image_data.image
transform = image_data.transform
height, width, depth = image.shape
assert depth == 4
bytes_per_row = width * depth
image = np.ascontiguousarray(np.flip(image, axis=0))
pixmap = qg.QPixmap(
qg.QImage(
image.data,
width,
height,
bytes_per_row,
qg.QImage.Format.Format_RGBA8888,
)
)
item = qw.QGraphicsPixmapItem()
item.setPixmap(pixmap)
item.setTransformationMode(qc.Qt.TransformationMode.SmoothTransformation)
item.setTransform(_matrix_to_qtransform(transform))
self._add_item(item, properties.handle)
def clear(self) -> None:
self._scene.clear()
def finalize(self) -> None:
super().finalize()
self._scene.setSceneRect(self._scene.itemsBoundingRect())
class PyQtBackend(_PyQtBackend):
"""
Backend which uses the :mod:`PySide6` package to implement an interactive
viewer. The :mod:`PyQt5` package can be used as fallback if the :mod:`PySide6`
package is not available.
Args:
scene: drawing canvas of type :class:`QtWidgets.QGraphicsScene`,
if ``None`` a new canvas will be created
"""
def __init__(
self,
scene: Optional[qw.QGraphicsScene] = None,
):
super().__init__(scene or qw.QGraphicsScene())
# This implementation keeps all virtual entities alive by attaching references
# to entities to the graphic scene items.
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
parent_stack = tuple(e for e, props in self.entity_stack[:-1])
current_entity = self.current_entity
item.setData(CorrespondingDXFEntity, current_entity)
item.setData(CorrespondingDXFParentStack, parent_stack)
class PyQtPlaybackBackend(_PyQtBackend):
"""
Backend which uses the :mod:`PySide6` package to implement an interactive
viewer. The :mod:`PyQt5` package can be used as fallback if the :mod:`PySide6`
package is not available.
This backend can be used a playback backend for the :meth:`replay` method of the
:class:`Player` class
Args:
scene: drawing canvas of type :class:`QtWidgets.QGraphicsScene`,
if ``None`` a new canvas will be created
"""
def __init__(
self,
scene: Optional[qw.QGraphicsScene] = None,
):
super().__init__(scene or qw.QGraphicsScene())
# The backend recorder does not record enter_entity() and exit_entity() events.
# This implementation attaches only entity handles (str) to the graphic scene items.
# Each item references the top level entity e.g. all items of a block reference
# references the handle of the INSERT entity.
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
item.setData(CorrespondingDXFEntity, entity_handle)
class _CosmeticPath(qw.QGraphicsPathItem):
def paint(
self,
painter: qg.QPainter,
option: qw.QStyleOptionGraphicsItem,
widget: Optional[qw.QWidget] = None,
) -> None:
_set_cosmetic_brush(self, painter)
super().paint(painter, option, widget)
class _CosmeticPolygon(qw.QGraphicsPolygonItem):
def paint(
self,
painter: qg.QPainter,
option: qw.QStyleOptionGraphicsItem,
widget: Optional[qw.QWidget] = None,
) -> None:
_set_cosmetic_brush(self, painter)
super().paint(painter, option, widget)
def _set_cosmetic_brush(
item: qw.QAbstractGraphicsShapeItem, painter: qg.QPainter
) -> None:
"""like a cosmetic pen, this sets the brush pattern to appear the same independent of the view"""
brush = item.brush()
# scale by -1 in y because the view is always mirrored in y and undoing the view transformation entirely would make
# the hatch mirrored w.r.t the view
brush.setTransform(painter.transform().inverted()[0].scale(1, -1)) # type: ignore
item.setBrush(brush)
def _get_x_scale(t: qg.QTransform) -> float:
return math.sqrt(t.m11() * t.m11() + t.m21() * t.m21())
def _matrix_to_qtransform(matrix: Matrix44) -> qg.QTransform:
"""Qt also uses row-vectors so the translation elements are placed in the
bottom row.
This is only a simple conversion which assumes that although the
transformation is 4x4,it does not involve the z axis.
A more correct transformation could be implemented like so:
https://stackoverflow.com/questions/10629737/convert-3d-4x4-rotation-matrix-into-2d
"""
return qg.QTransform(*matrix.get_2d_transformation())