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
|