523 lines
18 KiB
Python
523 lines
18 KiB
Python
# 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
|