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

523 lines
18 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Any, TYPE_CHECKING
import math
import os
import pathlib
from ezdxf.addons import xplayer
from ezdxf.addons.xqt import QtWidgets, QtGui, QtCore, QMessageBox
from ezdxf.addons.drawing import svg, layout, pymupdf, dxf
from ezdxf.addons.drawing.qtviewer import CADGraphicsView
from ezdxf.addons.drawing.pyqt import PyQtPlaybackBackend
from . import api
from .deps import BoundingBox2d, Matrix44, colors
if TYPE_CHECKING:
from ezdxf.document import Drawing
VIEWER_NAME = "HPGL/2 Viewer"
class HPGL2Widget(QtWidgets.QWidget):
def __init__(self, view: CADGraphicsView) -> None:
super().__init__()
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(view)
self.setLayout(layout)
self._view = view
self._view.closing.connect(self.close)
self._player: api.Player = api.Player([], {})
self._reset_backend()
def _reset_backend(self) -> None:
self._backend = PyQtPlaybackBackend()
@property
def view(self) -> CADGraphicsView:
return self._view
@property
def player(self) -> api.Player:
return self._player.copy()
def plot(self, data: bytes) -> None:
self._reset_backend()
self._player = api.record_plotter_output(data, api.MergeControl.AUTO)
def replay(
self, bg_color="#ffffff", override=None, reset_view: bool = True
) -> None:
self._reset_backend()
self._view.begin_loading()
new_scene = QtWidgets.QGraphicsScene()
self._backend.set_scene(new_scene)
new_scene.addItem(self._bg_paper(bg_color))
xplayer.hpgl2_to_drawing(
self._player, self._backend, bg_color="", override=override
)
self._view.end_loading(new_scene)
self._view.buffer_scene_rect()
if reset_view:
self._view.fit_to_scene()
def _bg_paper(self, color):
bbox = self._player.bbox()
insert = bbox.extmin
size = bbox.size
rect = QtWidgets.QGraphicsRectItem(insert.x, insert.y, size.x, size.y)
rect.setBrush(QtGui.QBrush(QtGui.QColor(color)))
return rect
SPACING = 20
DEFAULT_DPI = 96
COLOR_SCHEMA = [
"Default",
"Black on White",
"White on Black",
"Monochrome Light",
"Monochrome Dark",
"Blueprint High Contrast",
"Blueprint Low Contrast",
]
class HPGL2Viewer(QtWidgets.QMainWindow):
def __init__(self) -> None:
super().__init__()
self._cad = HPGL2Widget(CADGraphicsView())
self._view = self._cad.view
self._player: api.Player = api.Player([], {})
self._bbox: BoundingBox2d = BoundingBox2d()
self._page_rotation = 0
self._color_scheme = 0
self._current_file = pathlib.Path()
self.page_size_label = QtWidgets.QLabel()
self.png_size_label = QtWidgets.QLabel()
self.message_label = QtWidgets.QLabel()
self.scaling_factor_line_edit = QtWidgets.QLineEdit("1")
self.dpi_line_edit = QtWidgets.QLineEdit(str(DEFAULT_DPI))
self.flip_x_check_box = QtWidgets.QCheckBox("Horizontal")
self.flip_x_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.flip_x_check_box.stateChanged.connect(self.update_view)
self.flip_y_check_box = QtWidgets.QCheckBox("Vertical")
self.flip_y_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.flip_y_check_box.stateChanged.connect(self.update_view)
self.rotation_combo_box = QtWidgets.QComboBox()
self.rotation_combo_box.addItems(["0", "90", "180", "270"])
self.rotation_combo_box.currentIndexChanged.connect(self.update_rotation)
self.color_combo_box = QtWidgets.QComboBox()
self.color_combo_box.addItems(COLOR_SCHEMA)
self.color_combo_box.currentIndexChanged.connect(self.update_colors)
self.aci_export_mode = QtWidgets.QCheckBox("ACI Export Mode")
self.aci_export_mode.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.export_svg_button = QtWidgets.QPushButton("Export SVG")
self.export_svg_button.clicked.connect(self.export_svg)
self.export_png_button = QtWidgets.QPushButton("Export PNG")
self.export_png_button.clicked.connect(self.export_png)
self.export_pdf_button = QtWidgets.QPushButton("Export PDF")
self.export_pdf_button.clicked.connect(self.export_pdf)
self.export_dxf_button = QtWidgets.QPushButton("Export DXF")
self.export_dxf_button.clicked.connect(self.export_dxf)
self.disable_export_buttons(True)
self.scaling_factor_line_edit.editingFinished.connect(self.update_sidebar)
self.dpi_line_edit.editingFinished.connect(self.update_sidebar)
layout = QtWidgets.QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
container = QtWidgets.QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
layout.addWidget(self._cad)
sidebar = self.make_sidebar()
layout.addWidget(sidebar)
self.setWindowTitle(VIEWER_NAME)
self.resize(1600, 900)
self.show()
def reset_values(self):
self.scaling_factor_line_edit.setText("1")
self.dpi_line_edit.setText(str(DEFAULT_DPI))
self.flip_x_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.flip_y_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.rotation_combo_box.setCurrentIndex(0)
self._page_rotation = 0
self.update_view()
def make_sidebar(self) -> QtWidgets.QWidget:
sidebar = QtWidgets.QWidget()
v_layout = QtWidgets.QVBoxLayout()
v_layout.setContentsMargins(SPACING // 2, 0, SPACING // 2, 0)
sidebar.setLayout(v_layout)
policy = QtWidgets.QSizePolicy()
policy.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Fixed)
sidebar.setSizePolicy(policy)
open_button = QtWidgets.QPushButton("Open HPGL/2 File")
open_button.clicked.connect(self.select_plot_file)
v_layout.addWidget(open_button)
v_layout.addWidget(self.page_size_label)
h_layout = QtWidgets.QHBoxLayout()
h_layout.addWidget(QtWidgets.QLabel("Scaling Factor:"))
h_layout.addWidget(self.scaling_factor_line_edit)
v_layout.addLayout(h_layout)
h_layout = QtWidgets.QHBoxLayout()
h_layout.addWidget(QtWidgets.QLabel("Page Rotation:"))
h_layout.addWidget(self.rotation_combo_box)
v_layout.addLayout(h_layout)
group = QtWidgets.QGroupBox("Mirror Page")
h_layout = QtWidgets.QHBoxLayout()
h_layout.addWidget(self.flip_x_check_box)
h_layout.addWidget(self.flip_y_check_box)
group.setLayout(h_layout)
v_layout.addWidget(group)
h_layout = QtWidgets.QHBoxLayout()
h_layout.addWidget(QtWidgets.QLabel("Colors:"))
h_layout.addWidget(self.color_combo_box)
v_layout.addLayout(h_layout)
v_layout.addSpacing(SPACING)
h_layout = QtWidgets.QHBoxLayout()
h_layout.addWidget(QtWidgets.QLabel("DPI (PNG only):"))
h_layout.addWidget(self.dpi_line_edit)
v_layout.addLayout(h_layout)
v_layout.addWidget(self.png_size_label)
v_layout.addWidget(self.export_png_button)
v_layout.addWidget(self.export_svg_button)
v_layout.addWidget(self.export_pdf_button)
v_layout.addSpacing(SPACING)
v_layout.addWidget(self.aci_export_mode)
v_layout.addWidget(self.export_dxf_button)
v_layout.addSpacing(SPACING)
reset_button = QtWidgets.QPushButton("Reset")
reset_button.clicked.connect(self.reset_values)
v_layout.addWidget(reset_button)
v_layout.addSpacing(SPACING)
v_layout.addWidget(self.message_label)
return sidebar
def disable_export_buttons(self, disabled: bool):
self.export_svg_button.setDisabled(disabled)
self.export_dxf_button.setDisabled(disabled)
if pymupdf.is_pymupdf_installed:
self.export_png_button.setDisabled(disabled)
self.export_pdf_button.setDisabled(disabled)
else:
print("PDF/PNG export requires the PyMuPdf package!")
self.export_png_button.setDisabled(True)
self.export_pdf_button.setDisabled(True)
def load_plot_file(self, path: str | os.PathLike, force=False) -> None:
try:
with open(path, "rb") as fp:
data = fp.read()
if force:
data = b"%1B" + data
self.set_plot_data(data, path)
except IOError as e:
QtWidgets.QMessageBox.critical(self, "Loading Error", str(e))
def select_plot_file(self) -> None:
path, _ = QtWidgets.QFileDialog.getOpenFileName(
self,
dir=str(self._current_file.parent),
caption="Select HPGL/2 Plot File",
filter="Plot Files (*.plt)",
)
if path:
self.load_plot_file(path)
def set_plot_data(self, data: bytes, filename: str | os.PathLike) -> None:
try:
self._cad.plot(data)
except api.Hpgl2Error as e:
msg = f"Cannot plot HPGL/2 file '{filename}', {str(e)}"
QtWidgets.QMessageBox.critical(self, "Plot Error", msg)
return
self._player = self._cad.player
self._bbox = self._player.bbox()
self._current_file = pathlib.Path(filename)
self.update_colors(self._color_scheme)
self.update_sidebar()
self.setWindowTitle(f"{VIEWER_NAME} - " + str(filename))
self.disable_export_buttons(False)
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
self._view.fit_to_scene()
def get_scale_factor(self) -> float:
try:
return float(self.scaling_factor_line_edit.text())
except ValueError:
return 1.0
def get_dpi(self) -> int:
try:
return int(self.dpi_line_edit.text())
except ValueError:
return DEFAULT_DPI
def get_page_size(self) -> tuple[int, int]:
factor = self.get_scale_factor()
x = 0
y = 0
if self._bbox.has_data:
size = self._bbox.size
# 40 plot units = 1mm
x = round(size.x / 40 * factor)
y = round(size.y / 40 * factor)
if self._page_rotation in (90, 270):
x, y = y, x
return x, y
def get_pixel_size(self) -> tuple[int, int]:
dpi = self.get_dpi()
x, y = self.get_page_size()
return round(x / 25.4 * dpi), round(y / 25.4 * dpi)
def get_flip_x(self) -> bool:
return self.flip_x_check_box.checkState() == QtCore.Qt.CheckState.Checked
def get_flip_y(self) -> bool:
return self.flip_y_check_box.checkState() == QtCore.Qt.CheckState.Checked
def get_aci_export_mode(self) -> bool:
return self.aci_export_mode.checkState() == QtCore.Qt.CheckState.Checked
def update_sidebar(self):
x, y = self.get_page_size()
self.page_size_label.setText(f"Page Size: {x}x{y}mm")
px, py = self.get_pixel_size()
self.png_size_label.setText(f"PNG Size: {px}x{py}px")
self.clear_message()
def update_view(self):
self._view.setTransform(self.view_transformation())
self._view.fit_to_scene()
self.update_sidebar()
def update_rotation(self, index: int):
rotation = index * 90
if rotation != self._page_rotation:
self._page_rotation = rotation
self.update_view()
def update_colors(self, index: int):
self._color_scheme = index
self._cad.replay(*replay_properties(index))
self.update_view()
def view_transformation(self):
if self._page_rotation == 0:
m = Matrix44()
else:
m = Matrix44.z_rotate(math.radians(self._page_rotation))
sx = -1 if self.get_flip_x() else 1
# inverted y-axis
sy = 1 if self.get_flip_y() else -1
m @= Matrix44.scale(sx, sy, 1)
return QtGui.QTransform(*m.get_2d_transformation())
def show_message(self, msg: str) -> None:
self.message_label.setText(msg)
def clear_message(self) -> None:
self.message_label.setText("")
def get_export_name(self, suffix: str) -> str:
return str(self._current_file.with_suffix(suffix))
def export_svg(self) -> None:
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
dir=self.get_export_name(".svg"),
caption="Save SVG File",
filter="SVG Files (*.svg)",
)
if not path:
return
try:
with open(path, "wt") as fp:
fp.write(self.make_svg_string())
self.show_message("SVG successfully exported")
except IOError as e:
QMessageBox.critical(self, "Export Error", str(e))
def get_export_matrix(self) -> Matrix44:
scale = self.get_scale_factor()
rotation = self._page_rotation
sx = -scale if self.get_flip_x() else scale
sy = -scale if self.get_flip_y() else scale
if rotation in (90, 270):
sx, sy = sy, sx
m = Matrix44.scale(sx, sy, 1)
if rotation:
m @= Matrix44.z_rotate(math.radians(rotation))
return m
def make_svg_string(self) -> str:
"""Replays the HPGL/2 recordings on the SVGBackend of the drawing add-on."""
player = self._player.copy()
player.transform(self.get_export_matrix())
size = player.bbox().size
svg_backend = svg.SVGBackend()
bg_color, override = replay_properties(self._color_scheme)
xplayer.hpgl2_to_drawing(
player, svg_backend, bg_color=bg_color, override=override
)
del player # free memory as soon as possible
# 40 plot units == 1mm
page = layout.Page(width=size.x / 40, height=size.y / 40)
return svg_backend.get_string(page)
def export_pdf(self) -> None:
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
dir=self.get_export_name(".pdf"),
caption="Save PDF File",
filter="PDF Files (*.pdf)",
)
if not path:
return
try:
with open(path, "wb") as fp:
fp.write(self._pymupdf_export(fmt="pdf"))
self.show_message("PDF successfully exported")
except IOError as e:
QMessageBox.critical(self, "Export Error", str(e))
def export_png(self) -> None:
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
dir=self.get_export_name(".png"),
caption="Save PNG File",
filter="PNG Files (*.png)",
)
if not path:
return
try:
with open(path, "wb") as fp:
fp.write(self._pymupdf_export(fmt="png"))
self.show_message("PNG successfully exported")
except IOError as e:
QMessageBox.critical(self, "Export Error", str(e))
def _pymupdf_export(self, fmt: str) -> bytes:
"""Replays the HPGL/2 recordings on the PyMuPdfBackend of the drawing add-on."""
player = self._player.copy()
player.transform(self.get_export_matrix())
size = player.bbox().size
pdf_backend = pymupdf.PyMuPdfBackend()
bg_color, override = replay_properties(self._color_scheme)
xplayer.hpgl2_to_drawing(
player, pdf_backend, bg_color=bg_color, override=override
)
del player # free memory as soon as possible
# 40 plot units == 1mm
page = layout.Page(width=size.x / 40, height=size.y / 40)
if fmt == "pdf":
return pdf_backend.get_pdf_bytes(page)
else:
return pdf_backend.get_pixmap_bytes(page, fmt=fmt, dpi=self.get_dpi())
def export_dxf(self) -> None:
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
dir=self.get_export_name(".dxf"),
caption="Save DXF File",
filter="DXF Files (*.dxf)",
)
if not path:
return
doc = self._get_dxf_document()
try:
doc.saveas(path)
self.show_message("DXF successfully exported")
except IOError as e:
QMessageBox.critical(self, "Export Error", str(e))
def _get_dxf_document(self) -> Drawing:
import ezdxf
from ezdxf import zoom
color_mode = (
dxf.ColorMode.ACI if self.get_aci_export_mode() else dxf.ColorMode.RGB
)
doc = ezdxf.new()
msp = doc.modelspace()
player = self._player.copy()
bbox = player.bbox()
m = self.get_export_matrix()
corners = m.fast_2d_transform(bbox.rect_vertices())
# move content to origin:
tx, ty = BoundingBox2d(corners).extmin
m @= Matrix44.translate(-tx, -ty, 0)
player.transform(m)
bbox = player.bbox()
dxf_backend = dxf.DXFBackend(msp, color_mode=color_mode)
bg_color, override = replay_properties(self._color_scheme)
if color_mode == dxf.ColorMode.RGB:
doc.layers.add("BACKGROUND")
bg = dxf.add_background(msp, bbox, colors.RGB.from_hex(bg_color))
bg.dxf.layer = "BACKGROUND"
# exports the HPGL/2 content in plot units (plu) as modelspace:
# 1 plu = 0.025mm or 40 plu == 1mm
xplayer.hpgl2_to_drawing(
player, dxf_backend, bg_color=bg_color, override=override
)
del player
if bbox.has_data: # non-empty page
zoom.window(msp, bbox.extmin, bbox.extmax)
dxf.update_extents(doc, bbox)
# paperspace is set up in mm:
dxf.setup_paperspace(doc, bbox)
return doc
def replay_properties(index: int) -> tuple[str, Any]:
bg_color, override = "#ffffff", None # default
if index == 1: # black on white
bg_color, override = "#ffffff", xplayer.map_color("#000000")
elif index == 2: # white on black
bg_color, override = "#000000", xplayer.map_color("#ffffff")
elif index == 3: # monochrome light
bg_color, override = "#ffffff", xplayer.map_monochrome(dark_mode=False)
elif index == 4: # monochrome dark
bg_color, override = "#000000", xplayer.map_monochrome(dark_mode=True)
elif index == 5: # blueprint high contrast
bg_color, override = "#192c64", xplayer.map_color("#e9ebf3")
elif index == 6: # blueprint low contrast
bg_color, override = "#243f8f", xplayer.map_color("#bdc5dd")
return bg_color, override