refactor: excel parse
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
#
|
||||
# 1 plot unit (plu) = 0.025mm
|
||||
# 40 plu = 1mm
|
||||
# 1016 plu = 1 inch
|
||||
# 3.39 plu = 1 dot @300 dpi
|
||||
@@ -0,0 +1,398 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf import zoom, colors
|
||||
from ezdxf.addons import xplayer
|
||||
from ezdxf.addons.drawing import svg, layout, pymupdf, dxf
|
||||
from ezdxf.addons.drawing.dxf import ColorMode
|
||||
|
||||
from .tokenizer import hpgl2_commands
|
||||
from .plotter import Plotter
|
||||
from .interpreter import Interpreter
|
||||
from .backend import Recorder, placement_matrix, Player
|
||||
|
||||
|
||||
DEBUG = False
|
||||
ENTER_HPGL2_MODE = b"%1B"
|
||||
|
||||
|
||||
class Hpgl2Error(Exception):
|
||||
"""Base exception for the :mod:`hpgl2` add-on."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Hpgl2DataNotFound(Hpgl2Error):
|
||||
"""No HPGL/2 data was found, maybe the "Enter HPGL/2 mode" escape sequence is missing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class EmptyDrawing(Hpgl2Error):
|
||||
"""The HPGL/2 commands do not produce any content."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MergeControl(enum.IntEnum):
|
||||
NONE = 0 # print order
|
||||
LUMINANCE = 1 # sort filled polygons by luminance
|
||||
AUTO = 2 # guess best method
|
||||
|
||||
|
||||
def to_dxf(
|
||||
b: bytes,
|
||||
*,
|
||||
rotation: int = 0,
|
||||
mirror_x: bool = False,
|
||||
mirror_y: bool = False,
|
||||
color_mode=ColorMode.RGB,
|
||||
merge_control: MergeControl = MergeControl.AUTO,
|
||||
) -> Drawing:
|
||||
"""
|
||||
Exports the HPGL/2 commands of the byte stream `b` as a DXF document.
|
||||
|
||||
The page content is created at the origin of the modelspace and 1 drawing unit is 1
|
||||
plot unit (1 plu = 0.025mm) unless scaling values are provided.
|
||||
|
||||
The content of HPGL files is intended to be plotted on white paper, therefore a white
|
||||
filling will be added as background in color mode :attr:`RGB`.
|
||||
|
||||
All entities are assigned to a layer according to the pen number with the name scheme
|
||||
``PEN_<###>``. In order to be able to process the file better, it is also possible to
|
||||
assign the :term:`ACI` color by layer by setting the argument `color_mode` to
|
||||
:attr:`ColorMode.ACI`, but then the RGB color is lost because the RGB color has
|
||||
always the higher priority over the :term:`ACI`.
|
||||
|
||||
The first paperspace layout "Layout1" of the DXF document is set up to print the entire
|
||||
modelspace on one sheet, the size of the page is the size of the original plot file in
|
||||
millimeters.
|
||||
|
||||
HPGL/2's merge control works at the pixel level and cannot be replicated by DXF,
|
||||
but to prevent fillings from obscuring text, the filled polygons are
|
||||
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
|
||||
see also :class:`MergeControl` enum.
|
||||
|
||||
Args:
|
||||
b: plot file content as bytes
|
||||
rotation: rotation angle of 0, 90, 180 or 270 degrees
|
||||
mirror_x: mirror in x-axis direction
|
||||
mirror_y: mirror in y-axis direction
|
||||
color_mode: the color mode controls how color values are assigned to DXF entities,
|
||||
see :class:`ColorMode`
|
||||
merge_control: how to order filled polygons, see :class:`MergeControl`
|
||||
|
||||
Returns: DXF document as instance of class :class:`~ezdxf.document.Drawing`
|
||||
|
||||
"""
|
||||
if rotation not in (0, 90, 180, 270):
|
||||
raise ValueError("rotation angle must be 0, 90, 180, or 270")
|
||||
|
||||
# 1st pass records output of the plotting commands and detects the bounding box
|
||||
doc = ezdxf.new()
|
||||
try:
|
||||
player = record_plotter_output(b, merge_control)
|
||||
except Hpgl2Error:
|
||||
return doc
|
||||
|
||||
bbox = player.bbox()
|
||||
m = placement_matrix(bbox, -1 if mirror_x else 1, -1 if mirror_y else 1, rotation)
|
||||
player.transform(m)
|
||||
bbox = player.bbox()
|
||||
msp = doc.modelspace()
|
||||
dxf_backend = dxf.DXFBackend(msp, color_mode=color_mode)
|
||||
bg_color = colors.RGB(255, 255, 255)
|
||||
if color_mode == ColorMode.RGB:
|
||||
doc.layers.add("BACKGROUND")
|
||||
bg = dxf.add_background(msp, bbox, color=bg_color)
|
||||
bg.dxf.layer = "BACKGROUND"
|
||||
|
||||
# 2nd pass replays the plotting commands to plot the DXF
|
||||
# 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.to_hex())
|
||||
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 to_svg(
|
||||
b: bytes,
|
||||
*,
|
||||
rotation: int = 0,
|
||||
mirror_x: bool = False,
|
||||
mirror_y: bool = False,
|
||||
merge_control=MergeControl.AUTO,
|
||||
) -> str:
|
||||
"""
|
||||
Exports the HPGL/2 commands of the byte stream `b` as SVG string.
|
||||
|
||||
The plot units are mapped 1:1 to ``viewBox`` units and the size of image is the size
|
||||
of the original plot file in millimeters.
|
||||
|
||||
HPGL/2's merge control works at the pixel level and cannot be replicated by the
|
||||
backend, but to prevent fillings from obscuring text, the filled polygons are
|
||||
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
|
||||
see also :class:`MergeControl` enum.
|
||||
|
||||
Args:
|
||||
b: plot file content as bytes
|
||||
rotation: rotation angle of 0, 90, 180 or 270 degrees
|
||||
mirror_x: mirror in x-axis direction
|
||||
mirror_y: mirror in y-axis direction
|
||||
merge_control: how to order filled polygons, see :class:`MergeControl`
|
||||
|
||||
Returns: SVG content as ``str``
|
||||
|
||||
"""
|
||||
if rotation not in (0, 90, 180, 270):
|
||||
raise ValueError("rotation angle must be 0, 90, 180, or 270")
|
||||
# 1st pass records output of the plotting commands and detects the bounding box
|
||||
try:
|
||||
player = record_plotter_output(b, merge_control)
|
||||
except Hpgl2Error:
|
||||
return ""
|
||||
|
||||
# transform content for the SVGBackend of the drawing add-on
|
||||
bbox = player.bbox()
|
||||
size = bbox.size
|
||||
|
||||
# HPGL/2 uses (integer) plot units, 1 plu = 0.025mm or 1mm = 40 plu
|
||||
width_in_mm = size.x / 40
|
||||
height_in_mm = size.y / 40
|
||||
|
||||
if rotation in (0, 180):
|
||||
page = layout.Page(width_in_mm, height_in_mm)
|
||||
else:
|
||||
page = layout.Page(height_in_mm, width_in_mm)
|
||||
# adjust rotation for y-axis mirroring
|
||||
rotation += 180
|
||||
|
||||
# The SVGBackend expects coordinates in the range [0, output_coordinate_space]
|
||||
max_plot_units = round(max(size.x, size.y))
|
||||
settings = layout.Settings(output_coordinate_space=max_plot_units)
|
||||
m = layout.placement_matrix(
|
||||
bbox,
|
||||
sx=-1 if mirror_x else 1,
|
||||
sy=1 if mirror_y else -1, # inverted y-axis
|
||||
rotation=rotation,
|
||||
page=page,
|
||||
output_coordinate_space=settings.output_coordinate_space,
|
||||
)
|
||||
player.transform(m)
|
||||
|
||||
# 2nd pass replays the plotting commands on the SVGBackend of the drawing add-on
|
||||
svg_backend = svg.SVGRenderBackend(page, settings)
|
||||
xplayer.hpgl2_to_drawing(player, svg_backend, bg_color="#ffffff")
|
||||
del player
|
||||
xml = svg_backend.get_xml_root_element()
|
||||
return ET.tostring(xml, encoding="unicode", xml_declaration=True)
|
||||
|
||||
|
||||
def to_pdf(
|
||||
b: bytes,
|
||||
*,
|
||||
rotation: int = 0,
|
||||
mirror_x: bool = False,
|
||||
mirror_y: bool = False,
|
||||
merge_control=MergeControl.AUTO,
|
||||
) -> bytes:
|
||||
"""
|
||||
Exports the HPGL/2 commands of the byte stream `b` as PDF data.
|
||||
|
||||
The plot units (1 plu = 0.025mm) are converted to PDF units (1/72 inch) so the image
|
||||
has the size of the original plot file.
|
||||
|
||||
HPGL/2's merge control works at the pixel level and cannot be replicated by the
|
||||
backend, but to prevent fillings from obscuring text, the filled polygons are
|
||||
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
|
||||
see also :class:`MergeControl` enum.
|
||||
|
||||
Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/
|
||||
|
||||
Args:
|
||||
b: plot file content as bytes
|
||||
rotation: rotation angle of 0, 90, 180 or 270 degrees
|
||||
mirror_x: mirror in x-axis direction
|
||||
mirror_y: mirror in y-axis direction
|
||||
merge_control: how to order filled polygons, see :class:`MergeControl`
|
||||
|
||||
Returns: PDF content as ``bytes``
|
||||
|
||||
"""
|
||||
return _pymupdf(
|
||||
b,
|
||||
rotation=rotation,
|
||||
mirror_x=mirror_x,
|
||||
mirror_y=mirror_y,
|
||||
merge_control=merge_control,
|
||||
fmt="pdf",
|
||||
)
|
||||
|
||||
|
||||
def to_pixmap(
|
||||
b: bytes,
|
||||
*,
|
||||
rotation: int = 0,
|
||||
mirror_x: bool = False,
|
||||
mirror_y: bool = False,
|
||||
merge_control=MergeControl.AUTO,
|
||||
fmt: str = "png",
|
||||
dpi: int = 96,
|
||||
) -> bytes:
|
||||
"""
|
||||
Exports the HPGL/2 commands of the byte stream `b` as pixel image.
|
||||
|
||||
Supported image formats:
|
||||
|
||||
=== =========================
|
||||
png Portable Network Graphics
|
||||
ppm Portable Pixmap
|
||||
pbm Portable Bitmap
|
||||
=== =========================
|
||||
|
||||
The plot units (1 plu = 0.025mm) are converted to dot per inch (dpi) so the image
|
||||
has the size of the original plot file.
|
||||
|
||||
HPGL/2's merge control works at the pixel level and cannot be replicated by the
|
||||
backend, but to prevent fillings from obscuring text, the filled polygons are
|
||||
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
|
||||
see also :class:`MergeControl` enum.
|
||||
|
||||
Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/
|
||||
|
||||
Args:
|
||||
b: plot file content as bytes
|
||||
rotation: rotation angle of 0, 90, 180 or 270 degrees
|
||||
mirror_x: mirror in x-axis direction
|
||||
mirror_y: mirror in y-axis direction
|
||||
merge_control: how to order filled polygons, see :class:`MergeControl`
|
||||
fmt: image format
|
||||
dpi: output resolution in dots per inch
|
||||
|
||||
Returns: image content as ``bytes``
|
||||
|
||||
"""
|
||||
fmt = fmt.lower()
|
||||
if fmt not in pymupdf.SUPPORTED_IMAGE_FORMATS:
|
||||
raise ValueError(f"image format '{fmt}' not supported")
|
||||
return _pymupdf(
|
||||
b,
|
||||
rotation=rotation,
|
||||
mirror_x=mirror_x,
|
||||
mirror_y=mirror_y,
|
||||
merge_control=merge_control,
|
||||
fmt=fmt,
|
||||
dpi=dpi,
|
||||
)
|
||||
|
||||
|
||||
def _pymupdf(
|
||||
b: bytes,
|
||||
*,
|
||||
rotation: int = 0,
|
||||
mirror_x: bool = False,
|
||||
mirror_y: bool = False,
|
||||
merge_control=MergeControl.AUTO,
|
||||
fmt: str = "pdf",
|
||||
dpi=96,
|
||||
) -> bytes:
|
||||
if not pymupdf.is_pymupdf_installed:
|
||||
print("Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/")
|
||||
return b""
|
||||
|
||||
if rotation not in (0, 90, 180, 270):
|
||||
raise ValueError("rotation angle must be 0, 90, 180, or 270")
|
||||
# 1st pass records output of the plotting commands and detects the bounding box
|
||||
try:
|
||||
player = record_plotter_output(b, merge_control)
|
||||
except Hpgl2Error:
|
||||
return b""
|
||||
|
||||
# transform content for the SVGBackend of the drawing add-on
|
||||
bbox = player.bbox()
|
||||
size = bbox.size
|
||||
|
||||
# HPGL/2 uses (integer) plot units, 1 plu = 0.025mm or 1mm = 40 plu
|
||||
width_in_mm = size.x / 40
|
||||
height_in_mm = size.y / 40
|
||||
|
||||
if rotation in (0, 180):
|
||||
page = layout.Page(width_in_mm, height_in_mm)
|
||||
else:
|
||||
page = layout.Page(height_in_mm, width_in_mm)
|
||||
# adjust rotation for y-axis mirroring
|
||||
rotation += 180
|
||||
|
||||
# The PDFBackend expects coordinates as pt = 1/72 inch; 1016 plu = 1 inch
|
||||
max_plot_units = max(size.x, size.y) / 1016 * 72
|
||||
settings = layout.Settings(output_coordinate_space=max_plot_units)
|
||||
m = layout.placement_matrix(
|
||||
bbox,
|
||||
sx=-1 if mirror_x else 1,
|
||||
sy=-1 if mirror_y else 1,
|
||||
rotation=rotation,
|
||||
page=page,
|
||||
output_coordinate_space=settings.output_coordinate_space,
|
||||
)
|
||||
player.transform(m)
|
||||
|
||||
# 2nd pass replays the plotting commands on the PyMuPdfBackend of the drawing add-on
|
||||
pymupdf_backend = pymupdf.PyMuPdfBackend()
|
||||
xplayer.hpgl2_to_drawing(player, pymupdf_backend, bg_color="#ffffff")
|
||||
del player
|
||||
if fmt == "pdf":
|
||||
return pymupdf_backend.get_pdf_bytes(page, settings=settings)
|
||||
else:
|
||||
return pymupdf_backend.get_pixmap_bytes(
|
||||
page, fmt=fmt, settings=settings, dpi=dpi
|
||||
)
|
||||
|
||||
|
||||
def print_interpreter_log(interpreter: Interpreter) -> None:
|
||||
print("HPGL/2 interpreter log:")
|
||||
print(f"unsupported commands: {interpreter.not_implemented_commands}")
|
||||
if interpreter.errors:
|
||||
print("parsing errors:")
|
||||
for err in interpreter.errors:
|
||||
print(err)
|
||||
|
||||
|
||||
def record_plotter_output(
|
||||
b: bytes,
|
||||
merge_control: MergeControl,
|
||||
) -> Player:
|
||||
commands = hpgl2_commands(b)
|
||||
if len(commands) == 0:
|
||||
print("HPGL2 data not found.")
|
||||
raise Hpgl2DataNotFound
|
||||
|
||||
recorder = Recorder()
|
||||
plotter = Plotter(recorder)
|
||||
interpreter = Interpreter(plotter)
|
||||
interpreter.run(commands)
|
||||
if DEBUG:
|
||||
print_interpreter_log(interpreter)
|
||||
player = recorder.player()
|
||||
bbox = player.bbox()
|
||||
if not bbox.has_data:
|
||||
raise EmptyDrawing
|
||||
|
||||
if merge_control == MergeControl.AUTO:
|
||||
if plotter.has_merge_control:
|
||||
merge_control = MergeControl.LUMINANCE
|
||||
if merge_control == MergeControl.LUMINANCE:
|
||||
if DEBUG:
|
||||
print("merge control on: sorting filled polygons by luminance")
|
||||
player.sort_filled_paths()
|
||||
return player
|
||||
@@ -0,0 +1,237 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, NamedTuple, Any, Iterator
|
||||
from typing_extensions import Self
|
||||
import abc
|
||||
import copy
|
||||
import enum
|
||||
import math
|
||||
from .deps import (
|
||||
Vec2,
|
||||
Path,
|
||||
colors,
|
||||
Matrix44,
|
||||
BoundingBox2d,
|
||||
)
|
||||
from .properties import Properties, Pen
|
||||
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d
|
||||
|
||||
# Page coordinates are always plot units:
|
||||
# 1 plot unit (plu) = 0.025mm
|
||||
# 40 plu = 1mm
|
||||
# 1016 plu = 1 inch
|
||||
# 3.39 plu = 1 dot @300 dpi
|
||||
# positive x-axis is horizontal from left to right
|
||||
# positive y-axis is vertical from bottom to top
|
||||
|
||||
|
||||
class Backend(abc.ABC):
|
||||
"""Abstract base class for implementing a low level output backends."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None:
|
||||
"""Draws a polyline from a sequence `points`. The input coordinates are page
|
||||
coordinates in plot units. The `points` sequence can contain 0 or more
|
||||
points!
|
||||
|
||||
Args:
|
||||
properties: display :class:`Properties` for the polyline
|
||||
points: sequence of :class:`ezdxf.math.Vec2` instances
|
||||
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_paths(
|
||||
self, properties: Properties, paths: Sequence[Path], filled: bool
|
||||
) -> None:
|
||||
"""Draws filled or outline paths from the sequence of `paths`. The input coordinates
|
||||
are page coordinates in plot units. The `paths` sequence can contain 0 or more
|
||||
single :class:`~ezdxf.path.Path` instances. Draws outline paths if
|
||||
Properties.FillType is NONE and filled paths otherwise.
|
||||
|
||||
Args:
|
||||
properties: display :class:`Properties` for the filled polygon
|
||||
paths: sequence of single :class:`ezdxf.path.Path` instances
|
||||
filled: draw filled paths if ``True`` otherwise outline paths
|
||||
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class RecordType(enum.Enum):
|
||||
POLYLINE = enum.auto()
|
||||
FILLED_PATHS = enum.auto()
|
||||
OUTLINE_PATHS = enum.auto()
|
||||
|
||||
|
||||
class DataRecord(NamedTuple):
|
||||
type: RecordType
|
||||
property_hash: int
|
||||
data: Any
|
||||
|
||||
|
||||
class Recorder(Backend):
|
||||
"""The :class:`Recorder` class records the output of the :class:`Plotter` class.
|
||||
|
||||
All input coordinates are page coordinates:
|
||||
|
||||
- 1 plot unit (plu) = 0.025mm
|
||||
- 40 plu = 1 mm
|
||||
- 1016 plu = 1 inch
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._records: list[DataRecord] = []
|
||||
self._properties: dict[int, Properties] = {}
|
||||
self._pens: Sequence[Pen] = []
|
||||
|
||||
def player(self) -> Player:
|
||||
"""Returns a :class:`Player` instance with the original recordings. Make a copy
|
||||
of this player to protect the original recordings from being modified::
|
||||
|
||||
safe_player = recorder.player().copy()
|
||||
|
||||
"""
|
||||
return Player(self._records, self._properties)
|
||||
|
||||
def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None:
|
||||
self.store(RecordType.POLYLINE, properties, NumpyPoints2d(points))
|
||||
|
||||
def draw_paths(
|
||||
self, properties: Properties, paths: Sequence[Path], filled: bool
|
||||
) -> None:
|
||||
data = tuple(map(NumpyPath2d, paths))
|
||||
record_type = RecordType.FILLED_PATHS if filled else RecordType.OUTLINE_PATHS
|
||||
self.store(record_type, properties, data)
|
||||
|
||||
def store(self, record_type: RecordType, properties: Properties, args) -> None:
|
||||
prop_hash = properties.hash()
|
||||
if prop_hash not in self._properties:
|
||||
self._properties[prop_hash] = properties.copy()
|
||||
self._records.append(DataRecord(record_type, prop_hash, args))
|
||||
if len(self._pens) != len(properties.pen_table):
|
||||
self._pens = list(properties.pen_table.values())
|
||||
|
||||
|
||||
class Player:
|
||||
"""This class replays the recordings of the :class:`Recorder` class on another
|
||||
backend. The class can modify the recorded output.
|
||||
"""
|
||||
|
||||
def __init__(self, records: list[DataRecord], properties: dict[int, Properties]):
|
||||
self._records: list[DataRecord] = records
|
||||
self._properties: dict[int, Properties] = properties
|
||||
self._bbox = BoundingBox2d()
|
||||
|
||||
def __copy__(self) -> Self:
|
||||
"""Returns a new :class:`Player` instance with a copy of recordings."""
|
||||
records = copy.deepcopy(self._records)
|
||||
player = self.__class__(records, self._properties)
|
||||
player._bbox = self._bbox.copy()
|
||||
return player
|
||||
|
||||
copy = __copy__
|
||||
|
||||
def recordings(self) -> Iterator[tuple[RecordType, Properties, Any]]:
|
||||
"""Yields all recordings as `(RecordType, Properties, Data)` tuples.
|
||||
|
||||
The content of the `Data` field is determined by the enum :class:`RecordType`:
|
||||
|
||||
- :attr:`RecordType.POLYLINE` returns a :class:`NumpyPoints2d` instance
|
||||
- :attr:`RecordType.FILLED_POLYGON` returns a tuple of :class:`NumpyPath2d` instances
|
||||
|
||||
"""
|
||||
props = self._properties
|
||||
for record in self._records:
|
||||
yield record.type, props[record.property_hash], record.data
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
"""Returns the bounding box of all recorded polylines and polygons as
|
||||
:class:`~ezdxf.math.BoundingBox2d`.
|
||||
"""
|
||||
if not self._bbox.has_data:
|
||||
self.update_bbox()
|
||||
return self._bbox
|
||||
|
||||
def update_bbox(self) -> None:
|
||||
points: list[Vec2] = []
|
||||
for record in self._records:
|
||||
if record.type == RecordType.POLYLINE:
|
||||
points.extend(record.data.extents())
|
||||
else:
|
||||
for path in record.data:
|
||||
points.extend(path.extents())
|
||||
self._bbox = BoundingBox2d(points)
|
||||
|
||||
def replay(self, backend: Backend) -> None:
|
||||
"""Replay the recording on another backend."""
|
||||
current_props = Properties()
|
||||
props = self._properties
|
||||
for record in self._records:
|
||||
current_props = props.get(record.property_hash, current_props)
|
||||
if record.type == RecordType.POLYLINE:
|
||||
backend.draw_polyline(current_props, record.data.vertices())
|
||||
else:
|
||||
paths = [p.to_path2d() for p in record.data]
|
||||
backend.draw_paths(
|
||||
current_props, paths, filled=record.type == RecordType.FILLED_PATHS
|
||||
)
|
||||
|
||||
def transform(self, m: Matrix44) -> None:
|
||||
"""Transforms the recordings by a transformation matrix `m` of type
|
||||
:class:`~ezdxf.math.Matrix44`.
|
||||
"""
|
||||
for record in self._records:
|
||||
if record.type == RecordType.POLYLINE:
|
||||
record.data.transform_inplace(m)
|
||||
else:
|
||||
for path in record.data:
|
||||
path.transform_inplace(m)
|
||||
|
||||
if self._bbox.has_data:
|
||||
# fast, but maybe inaccurate update
|
||||
self._bbox = BoundingBox2d(m.fast_2d_transform(self._bbox.rect_vertices()))
|
||||
|
||||
def sort_filled_paths(self) -> None:
|
||||
"""Sort filled paths by descending luminance (from light to dark).
|
||||
|
||||
This also changes the plot order in the way that all filled paths are plotted
|
||||
before polylines and outline paths.
|
||||
"""
|
||||
fillings = []
|
||||
outlines = []
|
||||
current = Properties()
|
||||
props = self._properties
|
||||
for record in self._records:
|
||||
if record.type == RecordType.FILLED_PATHS:
|
||||
current = props.get(record.property_hash, current)
|
||||
key = colors.luminance(current.resolve_fill_color())
|
||||
fillings.append((key, record))
|
||||
else:
|
||||
outlines.append(record)
|
||||
|
||||
fillings.sort(key=lambda r: r[0], reverse=True)
|
||||
records = [sort_rec[1] for sort_rec in fillings]
|
||||
records.extend(outlines)
|
||||
self._records = records
|
||||
|
||||
|
||||
def placement_matrix(
|
||||
bbox: BoundingBox2d, sx: float = 1.0, sy: float = 1.0, rotation: float = 0.0
|
||||
) -> Matrix44:
|
||||
"""Returns a matrix to place the bbox in the first quadrant of the coordinate
|
||||
system (+x, +y).
|
||||
"""
|
||||
if abs(sx) < 1e-9:
|
||||
sx = 1.0
|
||||
if abs(sy) < 1e-9:
|
||||
sy = 1.0
|
||||
m = Matrix44.scale(sx, sy, 1.0)
|
||||
if rotation:
|
||||
m @= Matrix44.z_rotate(math.radians(rotation))
|
||||
corners = m.fast_2d_transform(bbox.rect_vertices())
|
||||
tx, ty = BoundingBox2d(corners).extmin
|
||||
return m @ Matrix44.translate(-tx, -ty, 0)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
#
|
||||
# Central import location of frontend dependencies.
|
||||
# To extract te hpgl2 add-on from the ezdxf package, the following tools have to be
|
||||
# implemented or extracted too. The dependencies of the implemented backends are not
|
||||
# listed here.
|
||||
|
||||
from ezdxf.math import Vec2, ConstructionCircle, BoundingBox2d, Bezier4P, AnyVec, Matrix44
|
||||
from ezdxf.path import Path, bbox as path_bbox, transform_paths
|
||||
from ezdxf.tools.standards import PAGE_SIZES
|
||||
from ezdxf import colors
|
||||
NULLVEC2 = Vec2(0, 0)
|
||||
@@ -0,0 +1,458 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterator, Iterable
|
||||
import string
|
||||
|
||||
from .deps import Vec2, NULLVEC2
|
||||
from .properties import RGB
|
||||
from .plotter import Plotter
|
||||
from .tokenizer import Command, pe_decode
|
||||
|
||||
|
||||
class Interpreter:
|
||||
"""The :class:`Interpreter` is the frontend for the :class:`Plotter` class.
|
||||
The :meth:`run` methods interprets the low level HPGL commands from the
|
||||
:func:`hpgl2_commands` parser and sends the commands to the virtual plotter
|
||||
device, which sends his output to a low level :class:`Backend` class.
|
||||
|
||||
Most CAD application send a very restricted subset of commands to plotters,
|
||||
mostly just polylines and filled polygons. Implementing the whole HPGL/2 command set
|
||||
is not worth the effort - unless reality proofs otherwise.
|
||||
|
||||
Not implemented commands:
|
||||
|
||||
- the whole character group - text is send as filled polygons or polylines
|
||||
- configuration group: IN, DF, RO, IW - the plotter is initialized by creating a
|
||||
new plotter and page rotation is handled by the add-on itself
|
||||
- polygon group: EA, ER, EW, FA, RR, WG, the rectangle and wedge commands
|
||||
- line and fill attributes group: LA, RF, SM, SV, TR, UL, WU, linetypes and
|
||||
hatch patterns are decomposed into simple lines by CAD applications
|
||||
|
||||
Args:
|
||||
plotter: virtual :class:`Plotter` device
|
||||
|
||||
"""
|
||||
def __init__(self, plotter: Plotter) -> None:
|
||||
self.errors: list[str] = []
|
||||
self.not_implemented_commands: set[str] = set()
|
||||
self._disabled_commands: set[str] = set()
|
||||
self.plotter = plotter
|
||||
|
||||
def add_error(self, error: str) -> None:
|
||||
self.errors.append(error)
|
||||
|
||||
def run(self, commands: list[Command]) -> None:
|
||||
"""Interprets the low level HPGL commands from the :func:`hpgl2_commands` parser
|
||||
and sends the commands to the virtual plotter device.
|
||||
"""
|
||||
for name, args in commands:
|
||||
if name in self._disabled_commands:
|
||||
continue
|
||||
method = getattr(self, f"cmd_{name.lower()}", None)
|
||||
if method:
|
||||
method(args)
|
||||
elif name[0] in string.ascii_letters:
|
||||
self.not_implemented_commands.add(name)
|
||||
|
||||
def disable_commands(self, commands: Iterable[str]) -> None:
|
||||
"""Disable commands manually, like the scaling command ["SC", "IP", "IR"].
|
||||
This is a feature for experts, because disabling commands which changes the pen
|
||||
location may distort or destroy the plotter output.
|
||||
"""
|
||||
self._disabled_commands.update(commands)
|
||||
|
||||
# Configure pens, line types, fill types
|
||||
def cmd_ft(self, args: list[bytes]):
|
||||
"""Set fill type."""
|
||||
fill_type = 1
|
||||
spacing = 0.0
|
||||
angle = 0.0
|
||||
values = tuple(to_floats(args))
|
||||
arg_count = len(values)
|
||||
if arg_count > 0:
|
||||
fill_type = int(values[0])
|
||||
if arg_count > 1:
|
||||
spacing = values[1]
|
||||
if arg_count > 2:
|
||||
angle = values[2]
|
||||
self.plotter.set_fill_type(fill_type, spacing, angle)
|
||||
|
||||
def cmd_pc(self, args: list[bytes]):
|
||||
"""Set pen color as RGB tuple."""
|
||||
values = list(to_ints(args))
|
||||
if len(values) == 4:
|
||||
index, r, g, b = values
|
||||
self.plotter.set_pen_color(index, RGB(r, g, b))
|
||||
else:
|
||||
self.add_error("invalid arguments for PC command")
|
||||
|
||||
def cmd_pw(self, args: list[bytes]):
|
||||
"""Set pen width."""
|
||||
arg_count = len(args)
|
||||
if arg_count:
|
||||
width = to_float(args[0], 0.35)
|
||||
else:
|
||||
self.add_error("invalid arguments for PW command")
|
||||
return
|
||||
index = -1
|
||||
if arg_count > 1:
|
||||
index = to_int(args[1], index)
|
||||
self.plotter.set_pen_width(index, width)
|
||||
|
||||
def cmd_sp(self, args: list[bytes]):
|
||||
"""Select pen."""
|
||||
if len(args):
|
||||
self.plotter.set_current_pen(to_int(args[0], 1))
|
||||
|
||||
def cmd_np(self, args: list[bytes]):
|
||||
"""Set number of pens."""
|
||||
if len(args):
|
||||
self.plotter.set_max_pen_count(to_int(args[0], 2))
|
||||
|
||||
def cmd_ip(self, args: list[bytes]):
|
||||
"""Set input points p1 and p2 absolute."""
|
||||
if len(args) == 0:
|
||||
self.plotter.reset_scaling()
|
||||
return
|
||||
|
||||
points = to_points(to_floats(args))
|
||||
if len(points) > 1:
|
||||
self.plotter.set_scaling_points(points[0], points[1])
|
||||
else:
|
||||
self.add_error("invalid arguments for IP command")
|
||||
|
||||
def cmd_ir(self, args: list[bytes]):
|
||||
"""Set input points p1 and p2 in percentage of page size."""
|
||||
if len(args) == 0:
|
||||
self.plotter.reset_scaling()
|
||||
return
|
||||
|
||||
values = list(to_floats(args))
|
||||
if len(values) == 2:
|
||||
xp1 = clamp(values[0], 0.0, 100.0)
|
||||
yp1 = clamp(values[1], 0.0, 100.0)
|
||||
self.plotter.set_scaling_points_relative_1(xp1 / 100.0, yp1 / 100.0)
|
||||
elif len(values) == 4:
|
||||
xp1 = clamp(values[0], 0.0, 100.0)
|
||||
yp1 = clamp(values[1], 0.0, 100.0)
|
||||
xp2 = clamp(values[2], 0.0, 100.0)
|
||||
yp2 = clamp(values[3], 0.0, 100.0)
|
||||
self.plotter.set_scaling_points_relative_2(
|
||||
xp1 / 100.0, yp1 / 100.0, xp2 / 100.0, yp2 / 100.0
|
||||
)
|
||||
else:
|
||||
self.add_error("invalid arguments for IP command")
|
||||
|
||||
def cmd_sc(self, args: list[bytes]):
|
||||
if len(args) == 0:
|
||||
self.plotter.reset_scaling()
|
||||
return
|
||||
values = list(to_floats(args))
|
||||
if len(values) < 4:
|
||||
self.add_error("invalid arguments for SC command")
|
||||
return
|
||||
scaling_type = 0
|
||||
if len(values) > 4:
|
||||
scaling_type = int(values[4])
|
||||
if scaling_type == 1: # isotropic
|
||||
left = 50.0
|
||||
if len(values) > 5:
|
||||
left = clamp(values[5], 0.0, 100.0)
|
||||
bottom = 50.0
|
||||
if len(values) > 6:
|
||||
bottom = clamp(values[6], 0.0, 100.0)
|
||||
self.plotter.set_isotropic_scaling(
|
||||
values[0],
|
||||
values[1],
|
||||
values[2],
|
||||
values[3],
|
||||
left,
|
||||
bottom,
|
||||
)
|
||||
elif scaling_type == 2: # point factor
|
||||
self.plotter.set_point_factor(
|
||||
Vec2(values[0], values[2]), values[1], values[3]
|
||||
)
|
||||
else: # anisotropic
|
||||
self.plotter.set_anisotropic_scaling(
|
||||
values[0], values[1], values[2], values[3]
|
||||
)
|
||||
|
||||
def cmd_mc(self, args: list[bytes]):
|
||||
status = 0
|
||||
if len(args):
|
||||
status = to_int(args[0], status)
|
||||
self.plotter.set_merge_control(bool(status))
|
||||
|
||||
def cmd_ps(self, args: list[bytes]):
|
||||
length = 1189 # A0
|
||||
height = 841
|
||||
count = len(args)
|
||||
if count:
|
||||
length = to_int(args[0], length)
|
||||
height = int(length * 1.5)
|
||||
if count > 1:
|
||||
height = to_int(args[1], height)
|
||||
self.plotter.setup_page(length, height)
|
||||
|
||||
# pen movement:
|
||||
def cmd_pd(self, args: list[bytes]):
|
||||
"""Lower pen down and plot lines."""
|
||||
self.plotter.pen_down()
|
||||
if len(args):
|
||||
self.plotter.plot_polyline(to_points(to_floats(args)))
|
||||
|
||||
def cmd_pu(self, args: list[bytes]):
|
||||
"""Lift pen up and move pen."""
|
||||
self.plotter.pen_up()
|
||||
if len(args):
|
||||
self.plotter.plot_polyline(to_points(to_floats(args)))
|
||||
|
||||
def cmd_pa(self, args: list[bytes]):
|
||||
"""Place pen absolute. Plots polylines if pen is down."""
|
||||
self.plotter.set_absolute_mode()
|
||||
if len(args):
|
||||
self.plotter.plot_polyline(to_points(to_floats(args)))
|
||||
|
||||
def cmd_pr(self, args: list[bytes]):
|
||||
"""Place pen relative.Plots polylines if pen is down."""
|
||||
self.plotter.set_relative_mode()
|
||||
if len(args):
|
||||
self.plotter.plot_polyline(to_points(to_floats(args)))
|
||||
|
||||
# plot commands:
|
||||
def cmd_ci(self, args: list[bytes]):
|
||||
"""Plot full circle."""
|
||||
arg_count = len(args)
|
||||
if not arg_count:
|
||||
self.add_error("invalid arguments for CI command")
|
||||
return
|
||||
self.plotter.push_pen_state()
|
||||
# implicit pen down!
|
||||
self.plotter.pen_down()
|
||||
radius = to_float(args[0], 1.0)
|
||||
chord_angle = 5.0
|
||||
if arg_count > 1:
|
||||
chord_angle = to_float(args[1], chord_angle)
|
||||
self.plotter.plot_abs_circle(radius, chord_angle)
|
||||
self.plotter.pop_pen_state()
|
||||
|
||||
def cmd_aa(self, args: list[bytes]):
|
||||
"""Plot arc absolute."""
|
||||
if len(args) < 3:
|
||||
self.add_error("invalid arguments for AR command")
|
||||
return
|
||||
self._arc_out(args, self.plotter.plot_abs_arc)
|
||||
|
||||
def cmd_ar(self, args: list[bytes]):
|
||||
"""Plot arc relative."""
|
||||
if len(args) < 3:
|
||||
self.add_error("invalid arguments for AR command")
|
||||
return
|
||||
self._arc_out(args, self.plotter.plot_rel_arc)
|
||||
|
||||
@staticmethod
|
||||
def _arc_out(args: list[bytes], output_method):
|
||||
"""Plot arc"""
|
||||
arg_count = len(args)
|
||||
if arg_count < 3:
|
||||
return
|
||||
x = to_float(args[0])
|
||||
y = to_float(args[1])
|
||||
sweep_angle = to_float(args[2])
|
||||
chord_angle = 5.0
|
||||
if arg_count > 3:
|
||||
chord_angle = to_float(args[3], chord_angle)
|
||||
output_method(Vec2(x, y), sweep_angle, chord_angle)
|
||||
|
||||
def cmd_at(self, args: list[bytes]):
|
||||
"""Plot arc absolute from three points."""
|
||||
if len(args) < 4:
|
||||
self.add_error("invalid arguments for AT command")
|
||||
return
|
||||
self._arc_3p_out(args, self.plotter.plot_abs_arc_three_points)
|
||||
|
||||
def cmd_rt(self, args: list[bytes]):
|
||||
"""Plot arc relative from three points."""
|
||||
if len(args) < 4:
|
||||
self.add_error("invalid arguments for RT command")
|
||||
return
|
||||
self._arc_3p_out(args, self.plotter.plot_rel_arc_three_points)
|
||||
|
||||
@staticmethod
|
||||
def _arc_3p_out(args: list[bytes], output_method):
|
||||
"""Plot arc from three points"""
|
||||
arg_count = len(args)
|
||||
if arg_count < 4:
|
||||
return
|
||||
points = to_points(to_floats(args))
|
||||
if len(points) < 2:
|
||||
return
|
||||
chord_angle = 5.0
|
||||
if arg_count > 4:
|
||||
chord_angle = to_float(args[4], chord_angle)
|
||||
try:
|
||||
output_method(points[0], points[1], chord_angle)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
def cmd_bz(self, args: list[bytes]):
|
||||
"""Plot cubic Bezier curves with absolute user coordinates."""
|
||||
self._bezier_out(args, self.plotter.plot_abs_cubic_bezier)
|
||||
|
||||
def cmd_br(self, args: list[bytes]):
|
||||
"""Plot cubic Bezier curves with relative user coordinates."""
|
||||
self._bezier_out(args, self.plotter.plot_rel_cubic_bezier)
|
||||
|
||||
@staticmethod
|
||||
def _bezier_out(args: list[bytes], output_method):
|
||||
kind = 0
|
||||
ctrl1 = NULLVEC2
|
||||
ctrl2 = NULLVEC2
|
||||
for point in to_points(to_floats(args)):
|
||||
if kind == 0:
|
||||
ctrl1 = point
|
||||
elif kind == 1:
|
||||
ctrl2 = point
|
||||
elif kind == 2:
|
||||
end = point
|
||||
output_method(ctrl1, ctrl2, end)
|
||||
kind = (kind + 1) % 3
|
||||
|
||||
def cmd_pe(self, args: list[bytes]):
|
||||
"""Plot Polyline Encoded."""
|
||||
if len(args):
|
||||
data = args[0]
|
||||
else:
|
||||
self.add_error("invalid arguments for PE command")
|
||||
return
|
||||
|
||||
plotter = self.plotter
|
||||
# The last pen up/down state remains after leaving the PE command.
|
||||
pen_down = True
|
||||
# Ignores and preserves the current absolute/relative mode of the plotter.
|
||||
absolute = False
|
||||
frac_bin_bits = 0
|
||||
base = 64
|
||||
index = 0
|
||||
length = len(data)
|
||||
point_queue: list[Vec2] = []
|
||||
|
||||
while index < length:
|
||||
char = data[index]
|
||||
if char in b":<>=7":
|
||||
index += 1
|
||||
if char == 58: # ":" - select pen
|
||||
values, index = pe_decode(data, base=base, start=index)
|
||||
plotter.set_current_pen(int(values[0]))
|
||||
if len(values) > 1:
|
||||
point_queue.extend(to_points(values[1:]))
|
||||
elif char == 60: # "<" - pen up and goto coordinates
|
||||
pen_down = False
|
||||
elif char == 62: # ">" - fractional data
|
||||
values, index = pe_decode(data, base=base, start=index)
|
||||
frac_bin_bits = int(values[0])
|
||||
if len(values) > 1:
|
||||
point_queue.extend(to_points(values[1:]))
|
||||
elif char == 61: # "=" - next coordinates are absolute
|
||||
absolute = True
|
||||
elif char == 55: # "7" - 7-bit mode
|
||||
base = 32
|
||||
else:
|
||||
values, index = pe_decode(
|
||||
data, frac_bits=frac_bin_bits, base=base, start=index
|
||||
)
|
||||
point_queue.extend(to_points(values))
|
||||
|
||||
if point_queue:
|
||||
plotter.pen_down()
|
||||
if absolute:
|
||||
# next point is absolute: make relative
|
||||
point_queue[0] = point_queue[0] - plotter.user_location
|
||||
if not pen_down:
|
||||
target = point_queue.pop(0)
|
||||
plotter.move_to_rel(target)
|
||||
if not point_queue: # last point in queue
|
||||
plotter.pen_up()
|
||||
if point_queue:
|
||||
plotter.plot_rel_polyline(point_queue)
|
||||
point_queue.clear()
|
||||
pen_down = True
|
||||
absolute = False
|
||||
|
||||
# polygon mode:
|
||||
def cmd_pm(self, args: list[bytes]) -> None:
|
||||
"""Enter/Exit polygon mode."""
|
||||
status = 0
|
||||
if len(args):
|
||||
status = to_int(args[0], status)
|
||||
if status == 2:
|
||||
self.plotter.exit_polygon_mode()
|
||||
else:
|
||||
self.plotter.enter_polygon_mode(status)
|
||||
|
||||
def cmd_fp(self, args: list[bytes]) -> None:
|
||||
"""Plot filled polygon."""
|
||||
fill_method = 0
|
||||
if len(args):
|
||||
fill_method = one_of(to_int(args[0], fill_method), (0, 1))
|
||||
self.plotter.fill_polygon(fill_method)
|
||||
|
||||
def cmd_ep(self, _) -> None:
|
||||
"""Plot edged polygon."""
|
||||
self.plotter.edge_polygon()
|
||||
|
||||
|
||||
def to_floats(args: Iterable[bytes]) -> Iterator[float]:
|
||||
for arg in args:
|
||||
try:
|
||||
yield float(arg)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def to_ints(args: Iterable[bytes]) -> Iterator[int]:
|
||||
for arg in args:
|
||||
try:
|
||||
yield int(arg)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def to_points(values: Iterable[float]) -> list[Vec2]:
|
||||
points: list[Vec2] = []
|
||||
append_point = False
|
||||
buffer: float = 0.0
|
||||
for value in values:
|
||||
if append_point:
|
||||
points.append(Vec2(buffer, value))
|
||||
append_point = False
|
||||
else:
|
||||
buffer = value
|
||||
append_point = True
|
||||
return points
|
||||
|
||||
|
||||
def to_float(s: bytes, default=0.0) -> float:
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def to_int(s: bytes, default=0) -> int:
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def clamp(v, v_min, v_max):
|
||||
return max(min(v_max, v), v_min)
|
||||
|
||||
|
||||
def one_of(value, choice):
|
||||
if value in choice:
|
||||
return value
|
||||
return choice[0]
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# 1 plot unit (plu) = 0.025mm
|
||||
# 40 plu = 1 mm
|
||||
# 1016 plu = 1 inch
|
||||
# 3.39 plu = 1 dot @300 dpi
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Sequence
|
||||
import math
|
||||
from .deps import Vec2, NULLVEC2
|
||||
|
||||
INCH_TO_PLU = 1016
|
||||
MM_TO_PLU = 40
|
||||
|
||||
|
||||
class Page:
|
||||
def __init__(self, size_x: int, size_y: int):
|
||||
self.size_x = int(size_x) # in plotter units (plu)
|
||||
self.size_y = int(size_y) # plu
|
||||
|
||||
self.p1 = NULLVEC2 # plu
|
||||
self.p2 = Vec2(size_x, size_y)
|
||||
self.user_scaling = False
|
||||
self.user_scale_x: float = 1.0
|
||||
self.user_scale_y: float = 1.0
|
||||
self.user_origin = NULLVEC2 # plu
|
||||
|
||||
def set_scaling_points(self, p1: Vec2, p2: Vec2) -> None:
|
||||
self.reset_scaling()
|
||||
self.p1 = Vec2(p1)
|
||||
self.p2 = Vec2(p2)
|
||||
|
||||
def apply_scaling_factors(self, sx: float, sy: float) -> None:
|
||||
self.set_ucs(self.user_origin, self.user_scale_x * sx, self.user_scale_y * sy)
|
||||
|
||||
def set_scaling_points_relative_1(self, xp1: float, yp1: float) -> None:
|
||||
size = self.p2 - self.p1
|
||||
p1 = Vec2(self.size_x * xp1, self.size_y * yp1)
|
||||
self.set_scaling_points(p1, p1 + size)
|
||||
|
||||
def set_scaling_points_relative_2(
|
||||
self, xp1: float, yp1: float, xp2: float, yp2: float
|
||||
) -> None:
|
||||
p1 = Vec2(self.size_x * xp1, self.size_y * yp1)
|
||||
p2 = Vec2(self.size_x * xp2, self.size_y * yp2)
|
||||
self.set_scaling_points(p1, p2)
|
||||
|
||||
def reset_scaling(self) -> None:
|
||||
self.p1 = NULLVEC2
|
||||
self.p2 = Vec2(self.size_x, self.size_y)
|
||||
self.set_ucs(NULLVEC2)
|
||||
|
||||
def set_isotropic_scaling(
|
||||
self,
|
||||
x_min: float,
|
||||
x_max: float,
|
||||
y_min: float,
|
||||
y_max: float,
|
||||
left=0.5,
|
||||
bottom=0.5,
|
||||
) -> None:
|
||||
size = self.p2 - self.p1
|
||||
delta_x = x_max - x_min
|
||||
delta_y = y_max - y_min
|
||||
scale_x = 1.0
|
||||
if abs(delta_x) > 1e-9:
|
||||
scale_x = size.x / delta_x
|
||||
scale_y = 1.0
|
||||
if abs(delta_y) > 1e-9:
|
||||
scale_y = size.y / delta_y
|
||||
|
||||
scale = min(abs(scale_x), abs(scale_y))
|
||||
scale_x = math.copysign(scale, scale_x)
|
||||
scale_y = math.copysign(scale, scale_y)
|
||||
offset_x = (size.x - delta_x * scale_x) * left
|
||||
offset_y = (size.y - delta_y * scale_y) * bottom
|
||||
origin_x = self.p1.x + offset_x - x_min * scale_x
|
||||
origin_y = self.p1.y + offset_y - y_min * scale_y
|
||||
self.set_ucs(Vec2(origin_x, origin_y), scale_x, scale_y)
|
||||
|
||||
def set_anisotropic_scaling(
|
||||
self, x_min: float, x_max: float, y_min: float, y_max: float
|
||||
) -> None:
|
||||
size = self.p2 - self.p1
|
||||
delta_x = x_max - x_min
|
||||
delta_y = y_max - y_min
|
||||
scale_x = 1.0
|
||||
if abs(delta_x) > 1e-9:
|
||||
scale_x = size.x / delta_x
|
||||
scale_y = 1.0
|
||||
if abs(delta_y) > 1e-9:
|
||||
scale_y = size.y / delta_y
|
||||
origin_x = self.p1.x - x_min * scale_x
|
||||
origin_y = self.p1.y - y_min * scale_y
|
||||
self.set_ucs(Vec2(origin_x, origin_y), scale_x, scale_y)
|
||||
|
||||
def set_ucs(self, origin: Vec2, sx: float = 1.0, sy: float = 1.0):
|
||||
self.user_origin = Vec2(origin)
|
||||
self.user_scale_x = float(sx)
|
||||
self.user_scale_y = float(sy)
|
||||
if abs(self.user_scale_x) < 1e-6:
|
||||
self.user_scale_x = 1.0
|
||||
if abs(self.user_scale_y) < 1e-6:
|
||||
self.user_scale_y = 1.0
|
||||
if math.isclose(self.user_scale_x, 1.0) and math.isclose(
|
||||
self.user_scale_y, 1.0
|
||||
):
|
||||
self.user_scaling = False
|
||||
else:
|
||||
self.user_scaling = True
|
||||
|
||||
def page_point(self, x: float, y: float) -> Vec2:
|
||||
"""Returns the page location as page point in plotter units."""
|
||||
return self.page_vector(x, y) + self.user_origin
|
||||
|
||||
def page_vector(self, x: float, y: float) -> Vec2:
|
||||
"""Returns the user vector in page vector in plotter units."""
|
||||
if self.user_scaling:
|
||||
x = self.user_scale_x * x
|
||||
y = self.user_scale_y * y
|
||||
return Vec2(x, y)
|
||||
|
||||
def page_points(self, points: Sequence[Vec2]) -> list[Vec2]:
|
||||
"""Returns all user points as page points in plotter units."""
|
||||
return [self.page_point(p.x, p.y) for p in points]
|
||||
|
||||
def page_vectors(self, vectors: Sequence[Vec2]) -> list[Vec2]:
|
||||
"""Returns all user vectors as page vectors in plotter units."""
|
||||
return [self.page_vector(p.x, p.y) for p in vectors]
|
||||
|
||||
def scale_length(self, length: float) -> tuple[float, float]:
|
||||
"""Scale a length in user units to plotter units, scaling can be non-uniform."""
|
||||
return length * self.user_scale_x, length * self.user_scale_y
|
||||
@@ -0,0 +1,315 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, Iterator
|
||||
import math
|
||||
|
||||
from .deps import (
|
||||
Vec2,
|
||||
Path,
|
||||
NULLVEC2,
|
||||
ConstructionCircle,
|
||||
Bezier4P,
|
||||
)
|
||||
from .properties import RGB, Properties, FillType
|
||||
from .backend import Backend
|
||||
from .polygon_buffer import PolygonBuffer
|
||||
from .page import Page
|
||||
|
||||
|
||||
class Plotter:
|
||||
"""
|
||||
The :class:`Plotter` class represents a virtual plotter device.
|
||||
|
||||
The HPGL/2 commands send by the :class:`Interpreter` are processed into simple
|
||||
polylines and filled polygons and send to low level :class:`Backend`.
|
||||
|
||||
HPGL/2 uses a units system called "Plot Units":
|
||||
|
||||
- 1 plot unit (plu) = 0.025mm
|
||||
- 40 plu = 1 mm
|
||||
- 1016 plu = 1 inch
|
||||
|
||||
The Plotter device does not support font rendering and page rotation (RO).
|
||||
The scaling commands IP, RP, SC are supported.
|
||||
|
||||
"""
|
||||
def __init__(self, backend: Backend) -> None:
|
||||
self.backend = backend
|
||||
self._output_backend = backend
|
||||
self._polygon_buffer = PolygonBuffer()
|
||||
self.page = Page(1189, 841)
|
||||
self.properties = Properties()
|
||||
self.is_pen_down = False
|
||||
self.is_absolute_mode = True
|
||||
self.is_polygon_mode = False
|
||||
self.has_merge_control = False
|
||||
self._user_location = NULLVEC2
|
||||
self._pen_state_stack: list[bool] = []
|
||||
|
||||
@property
|
||||
def user_location(self) -> Vec2:
|
||||
"""Returns the current pen location as point in the user coordinate system."""
|
||||
return self._user_location
|
||||
|
||||
@property
|
||||
def page_location(self) -> Vec2:
|
||||
"""Returns the current pen location as page point in plotter units."""
|
||||
location = self.user_location
|
||||
return self.page.page_point(location.x, location.y)
|
||||
|
||||
def setup_page(self, size_x: int, size_y: int):
|
||||
self.page = Page(size_x, size_y)
|
||||
|
||||
def set_scaling_points(self, p1: Vec2, p2: Vec2) -> None:
|
||||
self.page.set_scaling_points(p1, p2)
|
||||
|
||||
def set_scaling_points_relative_1(self, xp1: float, yp1: float) -> None:
|
||||
self.page.set_scaling_points_relative_1(xp1, yp1)
|
||||
|
||||
def set_scaling_points_relative_2(
|
||||
self, xp1: float, yp1: float, xp2: float, yp2: float
|
||||
) -> None:
|
||||
self.page.set_scaling_points_relative_2(xp1, yp1, xp2, yp2)
|
||||
|
||||
def reset_scaling(self) -> None:
|
||||
self.page.reset_scaling()
|
||||
|
||||
def set_point_factor(self, origin: Vec2, scale_x: float, scale_y: float) -> None:
|
||||
self.page.set_ucs(origin, scale_x, scale_y)
|
||||
|
||||
def set_isotropic_scaling(
|
||||
self,
|
||||
x_min: float,
|
||||
x_max: float,
|
||||
y_min: float,
|
||||
y_max: float,
|
||||
left=0.5,
|
||||
bottom=0.5,
|
||||
) -> None:
|
||||
self.page.set_isotropic_scaling(x_min, x_max, y_min, y_max, left, bottom)
|
||||
|
||||
def set_anisotropic_scaling(
|
||||
self, x_min: float, x_max: float, y_min: float, y_max: float
|
||||
) -> None:
|
||||
self.page.set_anisotropic_scaling(x_min, x_max, y_min, y_max)
|
||||
|
||||
def set_merge_control(self, status: bool) -> None:
|
||||
self.has_merge_control = status
|
||||
|
||||
def pen_up(self) -> None:
|
||||
self.is_pen_down = False
|
||||
|
||||
def pen_down(self) -> None:
|
||||
self.is_pen_down = True
|
||||
|
||||
def push_pen_state(self) -> None:
|
||||
self._pen_state_stack.append(self.is_pen_down)
|
||||
|
||||
def pop_pen_state(self) -> None:
|
||||
if len(self._pen_state_stack):
|
||||
self.is_pen_down = self._pen_state_stack.pop()
|
||||
|
||||
def move_to(self, location: Vec2) -> None:
|
||||
if self.is_absolute_mode:
|
||||
self.move_to_abs(location)
|
||||
else:
|
||||
self.move_to_rel(location)
|
||||
|
||||
def move_to_abs(self, user_location: Vec2) -> None:
|
||||
self._user_location = user_location
|
||||
|
||||
def move_to_rel(self, user_location: Vec2) -> None:
|
||||
self._user_location += user_location
|
||||
|
||||
def set_absolute_mode(self) -> None:
|
||||
self.is_absolute_mode = True
|
||||
|
||||
def set_relative_mode(self) -> None:
|
||||
self.is_absolute_mode = False
|
||||
|
||||
def set_current_pen(self, index: int) -> None:
|
||||
self.properties.set_current_pen(index)
|
||||
|
||||
def set_max_pen_count(self, index: int) -> None:
|
||||
self.properties.set_max_pen_count(index)
|
||||
|
||||
def set_pen_width(self, index: int, width: float) -> None:
|
||||
self.properties.set_pen_width(index, width)
|
||||
|
||||
def set_pen_color(self, index: int, color: RGB) -> None:
|
||||
self.properties.set_pen_color(index, color)
|
||||
|
||||
def set_fill_type(self, fill_type: int, spacing: float, angle: float) -> None:
|
||||
if fill_type in (3, 4): # adjust spacing between hatching lines
|
||||
spacing = max(self.page.scale_length(spacing))
|
||||
self.properties.set_fill_type(fill_type, spacing, angle)
|
||||
|
||||
def enter_polygon_mode(self, status: int) -> None:
|
||||
self.is_polygon_mode = True
|
||||
self.backend = self._polygon_buffer
|
||||
if status == 0:
|
||||
self._polygon_buffer.reset(self.page_location)
|
||||
elif status == 1:
|
||||
self._polygon_buffer.close_path()
|
||||
|
||||
def exit_polygon_mode(self) -> None:
|
||||
self.is_polygon_mode = False
|
||||
self._polygon_buffer.close_path()
|
||||
self.backend = self._output_backend
|
||||
|
||||
def fill_polygon(self, fill_method: int) -> None:
|
||||
self.properties.set_fill_method(fill_method)
|
||||
self.plot_filled_polygon_buffer(self._polygon_buffer.get_paths())
|
||||
|
||||
def edge_polygon(self) -> None:
|
||||
self.plot_outline_polygon_buffer(self._polygon_buffer.get_paths())
|
||||
|
||||
def plot_polyline(self, points: Sequence[Vec2]):
|
||||
if not points:
|
||||
return
|
||||
if self.is_absolute_mode:
|
||||
self.plot_abs_polyline(points)
|
||||
else:
|
||||
self.plot_rel_polyline(points)
|
||||
|
||||
def plot_abs_polyline(self, points: Sequence[Vec2]):
|
||||
# input coordinates are user coordinates
|
||||
if not points:
|
||||
return
|
||||
current_page_location = self.page_location
|
||||
self.move_to_abs(points[-1]) # user coordinates!
|
||||
if self.is_pen_down:
|
||||
# convert to page coordinates:
|
||||
points = self.page.page_points(points)
|
||||
# insert current page location as starting point:
|
||||
points.insert(0, current_page_location)
|
||||
# draw polyline in absolute page coordinates:
|
||||
self.backend.draw_polyline(self.properties, points)
|
||||
|
||||
def plot_rel_polyline(self, points: Sequence[Vec2]):
|
||||
# input coordinates are user coordinates
|
||||
if not points:
|
||||
return
|
||||
# convert to absolute user coordinates:
|
||||
self.plot_abs_polyline(
|
||||
tuple(rel_to_abs_points_dynamic(self.user_location, points))
|
||||
)
|
||||
|
||||
def plot_abs_circle(self, radius: float, chord_angle: float):
|
||||
# radius in user units
|
||||
if self.is_pen_down:
|
||||
center = self.user_location
|
||||
vertices = [
|
||||
center + Vec2.from_deg_angle(a, radius)
|
||||
for a in arc_angles(0, 360.0, chord_angle)
|
||||
]
|
||||
# draw circle in absolute page coordinates:
|
||||
self.backend.draw_polyline(self.properties, vertices)
|
||||
|
||||
def plot_abs_arc(self, center: Vec2, sweep_angle: float, chord_angle: float):
|
||||
start_point = self.user_location
|
||||
radius_vec = start_point - center
|
||||
radius = radius_vec.magnitude
|
||||
start_angle = radius_vec.angle_deg
|
||||
end_angle = start_angle + sweep_angle
|
||||
end_point = center + Vec2.from_deg_angle(end_angle, radius)
|
||||
|
||||
self.move_to_abs(end_point)
|
||||
if self.is_pen_down:
|
||||
vertices = [
|
||||
center + Vec2.from_deg_angle(a, radius)
|
||||
for a in arc_angles(start_angle, sweep_angle, chord_angle)
|
||||
]
|
||||
self.backend.draw_polyline(self.properties, vertices)
|
||||
|
||||
def plot_rel_arc(self, center_rel: Vec2, sweep_angle: float, chord_angle: float):
|
||||
self.plot_abs_arc(center_rel + self.user_location, sweep_angle, chord_angle)
|
||||
|
||||
def plot_abs_arc_three_points(self, inter: Vec2, end: Vec2, chord_angle: float):
|
||||
# input coordinates are user coordinates
|
||||
start = self.user_location
|
||||
circle = ConstructionCircle.from_3p(start, inter, end)
|
||||
center = circle.center
|
||||
start_angle = (start - center).angle_deg
|
||||
end_angle = (end - center).angle_deg
|
||||
inter_angle = (inter - center).angle_deg
|
||||
sweep_angle = sweeping_angle(start_angle, inter_angle, end_angle)
|
||||
self.plot_abs_arc(center, sweep_angle, chord_angle)
|
||||
|
||||
def plot_rel_arc_three_points(self, inter: Vec2, end: Vec2, chord_angle: float):
|
||||
# input coordinates are user coordinates
|
||||
current = self.user_location
|
||||
self.plot_abs_arc_three_points(current + inter, current + end, chord_angle)
|
||||
|
||||
def plot_abs_cubic_bezier(self, ctrl1: Vec2, ctrl2: Vec2, end: Vec2):
|
||||
# input coordinates are user coordinates
|
||||
current_page_location = self.page_location
|
||||
self.move_to_abs(end) # user coordinates!
|
||||
if self.is_pen_down:
|
||||
# convert to page coordinates:
|
||||
ctrl1, ctrl2, end = self.page.page_points((ctrl1, ctrl2, end))
|
||||
# draw cubic bezier curve in absolute page coordinates:
|
||||
p = Path(current_page_location)
|
||||
p.curve4_to(end, ctrl1, ctrl2)
|
||||
self.backend.draw_paths(self.properties, [p], filled=False)
|
||||
|
||||
def plot_rel_cubic_bezier(self, ctrl1: Vec2, ctrl2: Vec2, end: Vec2):
|
||||
# input coordinates are user coordinates
|
||||
ctrl1, ctrl2, end = rel_to_abs_points_static(
|
||||
self.user_location, (ctrl1, ctrl2, end)
|
||||
)
|
||||
self.plot_abs_cubic_bezier(ctrl1, ctrl2, end)
|
||||
|
||||
def plot_filled_polygon_buffer(self, paths: Sequence[Path]):
|
||||
# input coordinates are page coordinates!
|
||||
self.backend.draw_paths(self.properties, paths, filled=True)
|
||||
|
||||
def plot_outline_polygon_buffer(self, paths: Sequence[Path]):
|
||||
# input coordinates are page coordinates!
|
||||
self.backend.draw_paths(self.properties, paths, filled=False)
|
||||
|
||||
|
||||
def rel_to_abs_points_dynamic(current: Vec2, points: Sequence[Vec2]) -> Iterator[Vec2]:
|
||||
"""Returns the absolute location of increment points, each point is an increment
|
||||
of the previous point starting at the current pen location.
|
||||
"""
|
||||
for point in points:
|
||||
current += point
|
||||
yield current
|
||||
|
||||
|
||||
def rel_to_abs_points_static(current: Vec2, points: Sequence[Vec2]) -> Iterator[Vec2]:
|
||||
"""Returns the absolute location of increment points, all points are relative
|
||||
to the current pen location.
|
||||
"""
|
||||
for point in points:
|
||||
yield current + point
|
||||
|
||||
|
||||
def arc_angles(start: float, sweep_angle: float, chord_angle: float) -> Iterator[float]:
|
||||
# clamp to 0.5 .. 180
|
||||
chord_angle = min(180.0, max(0.5, chord_angle))
|
||||
count = abs(round(sweep_angle / chord_angle))
|
||||
delta = sweep_angle / count
|
||||
for index in range(count + 1):
|
||||
yield start + delta * index
|
||||
|
||||
|
||||
def sweeping_angle(start: float, intermediate: float, end: float) -> float:
|
||||
"""Returns the sweeping angle from start angle to end angle passing the
|
||||
intermediate angle.
|
||||
"""
|
||||
start = start % 360.0
|
||||
intermediate = intermediate % 360.0
|
||||
end = end % 360.0
|
||||
angle = end - start
|
||||
i_to_s = start - intermediate
|
||||
i_to_e = end - intermediate
|
||||
if math.isclose(abs(i_to_e) + abs(i_to_s), abs(angle)):
|
||||
return angle
|
||||
else: # return complementary angle with opposite orientation
|
||||
if angle < 0:
|
||||
return 360.0 + angle
|
||||
else:
|
||||
return angle - 360.0
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Sequence
|
||||
from .backend import Backend
|
||||
from .deps import Vec2, Path
|
||||
from .properties import Properties
|
||||
|
||||
|
||||
class PolygonBuffer(Backend):
|
||||
def __init__(self):
|
||||
self.path = Path()
|
||||
self.start_new_sub_polygon = False
|
||||
|
||||
def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None:
|
||||
if len(points) == 0:
|
||||
return
|
||||
index = 0
|
||||
if self.start_new_sub_polygon:
|
||||
self.start_new_sub_polygon = False
|
||||
count = len(points)
|
||||
while self.path.end.isclose(points[index]):
|
||||
index += 1
|
||||
if index == count:
|
||||
return
|
||||
self.path.move_to(points[index])
|
||||
for p in points[index + 1 :]:
|
||||
self.path.line_to(p)
|
||||
|
||||
def draw_paths(
|
||||
self, properties: Properties, paths: Sequence[Path], filled: bool
|
||||
) -> None:
|
||||
if filled:
|
||||
raise NotImplementedError()
|
||||
for p in paths:
|
||||
if len(p) == 0:
|
||||
continue
|
||||
if self.start_new_sub_polygon:
|
||||
self.start_new_sub_polygon = False
|
||||
self.path.move_to(p.start)
|
||||
self.path.append_path(p)
|
||||
|
||||
def get_paths(self) -> Sequence[Path]:
|
||||
return list(self.path.sub_paths())
|
||||
|
||||
def close_path(self):
|
||||
if len(self.path):
|
||||
self.path.close_sub_path()
|
||||
self.start_new_sub_polygon = True
|
||||
|
||||
def reset(self, location: Vec2) -> None:
|
||||
self.path = Path(location)
|
||||
self.start_new_sub_polygon = False
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import enum
|
||||
import copy
|
||||
from ezdxf.colors import RGB
|
||||
|
||||
|
||||
class FillType(enum.IntEnum):
|
||||
"""Fill type enumeration."""
|
||||
|
||||
NONE = 0
|
||||
SOLID = 1
|
||||
HATCHING = 2
|
||||
CROSS_HATCHING = 3
|
||||
SHADING = 4
|
||||
|
||||
|
||||
class FillMethod(enum.IntEnum):
|
||||
"""Fill method enumeration."""
|
||||
|
||||
EVEN_ODD = 0
|
||||
NON_ZERO_WINDING = 1
|
||||
|
||||
|
||||
RGB_NONE = RGB(-1, -1, -1)
|
||||
RGB_BLACK = RGB(0, 0, 0)
|
||||
RGB_WHITE = RGB(255, 255, 255)
|
||||
LIGHT_GREY = RGB(200, 200, 200)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Pen:
|
||||
"""Represents a pen table entry."""
|
||||
|
||||
index: int
|
||||
width: float # in mm
|
||||
color: RGB
|
||||
|
||||
|
||||
class Properties:
|
||||
"""Consolidated display properties."""
|
||||
|
||||
DEFAULT_PEN = Pen(1, 0.35, RGB_NONE)
|
||||
|
||||
def __init__(self) -> None:
|
||||
# hashed content
|
||||
self.pen_index: int = 1
|
||||
self.pen_color = RGB_NONE
|
||||
self.pen_width: float = 0.35 # in mm
|
||||
self.fill_type = FillType.SOLID
|
||||
self.fill_method = FillMethod.EVEN_ODD
|
||||
self.fill_hatch_line_angle: float = 0.0 # in degrees
|
||||
self.fill_hatch_line_spacing: float = 40.0 # in plotter units
|
||||
self.fill_shading_density: float = 100.0
|
||||
# not hashed content
|
||||
self.max_pen_count: int = 2
|
||||
self.pen_table: dict[int, Pen] = {}
|
||||
self.reset()
|
||||
|
||||
def hash(self) -> int:
|
||||
return hash(
|
||||
(
|
||||
self.pen_index,
|
||||
self.pen_color,
|
||||
self.pen_width,
|
||||
self.fill_type,
|
||||
self.fill_method,
|
||||
self.fill_hatch_line_angle,
|
||||
self.fill_hatch_line_spacing,
|
||||
self.fill_shading_density,
|
||||
)
|
||||
)
|
||||
|
||||
def copy(self) -> Properties:
|
||||
# the pen table is shared across all copies of Properties
|
||||
return copy.copy(self)
|
||||
|
||||
def setup_default_pen_table(self):
|
||||
if len(self.pen_table):
|
||||
return
|
||||
pens = self.pen_table
|
||||
width = self.DEFAULT_PEN.width
|
||||
pens[0] = Pen(0, width, RGB(255, 255, 255)) # white
|
||||
pens[1] = Pen(1, width, RGB(0, 0, 0)) # black
|
||||
pens[2] = Pen(2, width, RGB(255, 0, 0)) # red
|
||||
pens[3] = Pen(3, width, RGB(0, 255, 0)) # green
|
||||
pens[4] = Pen(4, width, RGB(255, 255, 0)) # yellow
|
||||
pens[5] = Pen(5, width, RGB(0, 0, 255)) # blue
|
||||
pens[6] = Pen(6, width, RGB(255, 0, 255)) # magenta
|
||||
pens[7] = Pen(6, width, RGB(0, 255, 255)) # cyan
|
||||
|
||||
def reset(self) -> None:
|
||||
self.max_pen_count = 2
|
||||
self.pen_index = self.DEFAULT_PEN.index
|
||||
self.pen_color = self.DEFAULT_PEN.color
|
||||
self.pen_width = self.DEFAULT_PEN.width
|
||||
self.pen_table = {}
|
||||
self.fill_type = FillType.SOLID
|
||||
self.fill_method = FillMethod.EVEN_ODD
|
||||
self.fill_hatch_line_angle = 0.0
|
||||
self.fill_hatch_line_spacing = 40.0
|
||||
self.fill_shading_density = 1.0
|
||||
self.setup_default_pen_table()
|
||||
|
||||
def get_pen(self, index: int) -> Pen:
|
||||
return self.pen_table.get(index, self.DEFAULT_PEN)
|
||||
|
||||
def set_max_pen_count(self, count: int) -> None:
|
||||
self.max_pen_count = count
|
||||
|
||||
def set_current_pen(self, index: int) -> None:
|
||||
self.pen_index = index
|
||||
pen = self.get_pen(index)
|
||||
self.pen_width = pen.width
|
||||
self.pen_color = pen.color
|
||||
|
||||
def set_pen_width(self, index: int, width: float) -> None:
|
||||
if index == -1:
|
||||
self.pen_width = width
|
||||
else:
|
||||
pen = self.pen_table.setdefault(
|
||||
index, Pen(index, width, self.DEFAULT_PEN.color)
|
||||
)
|
||||
pen.width = width
|
||||
|
||||
def set_pen_color(self, index: int, rgb: RGB) -> None:
|
||||
if index == -1:
|
||||
self.pen_color = rgb
|
||||
else:
|
||||
pen = self.pen_table.setdefault(
|
||||
index, Pen(index, self.DEFAULT_PEN.width, rgb)
|
||||
)
|
||||
pen.color = rgb
|
||||
|
||||
def set_fill_type(self, fill_type: int, spacing: float, angle: float):
|
||||
if fill_type == 3:
|
||||
self.fill_type = FillType.HATCHING
|
||||
self.fill_hatch_line_spacing = spacing
|
||||
self.fill_hatch_line_angle = angle
|
||||
elif fill_type == 4:
|
||||
self.fill_type = FillType.CROSS_HATCHING
|
||||
self.fill_hatch_line_spacing = spacing
|
||||
self.fill_hatch_line_angle = angle
|
||||
elif fill_type == 10:
|
||||
self.fill_type = FillType.SHADING
|
||||
self.fill_shading_density = spacing
|
||||
else:
|
||||
self.fill_type = FillType.SOLID
|
||||
|
||||
def set_fill_method(self, fill_method: int) -> None:
|
||||
self.fill_method = FillMethod(fill_method)
|
||||
|
||||
def resolve_pen_color(self) -> RGB:
|
||||
"""Returns the final RGB pen color."""
|
||||
rgb = self.pen_color
|
||||
if rgb is RGB_NONE:
|
||||
pen = self.pen_table.get(self.pen_index, self.DEFAULT_PEN)
|
||||
rgb = pen.color
|
||||
if rgb is RGB_NONE:
|
||||
return RGB_BLACK
|
||||
return rgb
|
||||
|
||||
def resolve_fill_color(self) -> RGB:
|
||||
"""Returns the final RGB fill color."""
|
||||
ft = self.fill_type
|
||||
if ft == FillType.SOLID:
|
||||
return self.resolve_pen_color()
|
||||
elif ft == FillType.SHADING:
|
||||
grey = min(int(2.55 * (100.0 - self.fill_shading_density)), 255)
|
||||
return RGB(grey, grey, grey)
|
||||
elif ft == FillType.HATCHING or ft == FillType.CROSS_HATCHING:
|
||||
return LIGHT_GREY
|
||||
return RGB_WHITE
|
||||
@@ -0,0 +1,224 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import NamedTuple, Sequence
|
||||
|
||||
|
||||
class Command(NamedTuple):
|
||||
name: str
|
||||
args: Sequence[bytes]
|
||||
|
||||
|
||||
ESCAPE = 27
|
||||
SEMICOLON = ord(";")
|
||||
PERCENT = ord("%")
|
||||
MINUS = ord("-")
|
||||
QUOTE_CHAR = ord('"')
|
||||
CHAR_A = ord("A")
|
||||
CHAR_B = ord("B")
|
||||
CHAR_Z = ord("Z")
|
||||
DEFAULT_TEXT_TERMINATOR = 3
|
||||
|
||||
# Enter HPGL/2 mode commands
|
||||
# b"%-0B", # ??? not documented (assumption)
|
||||
# b"%-1B", # ??? not documented (really exist)
|
||||
# b"%-2B", # ??? not documented (assumption)
|
||||
# b"%-3B", # ??? not documented (assumption)
|
||||
# b"%0B", # documented in the HPGL2 reference by HP
|
||||
# b"%1B", # documented
|
||||
# b"%2B", # documented
|
||||
# b"%3B", # documented
|
||||
|
||||
|
||||
def get_enter_hpgl2_mode_command_length(s: bytes, i: int) -> int:
|
||||
try:
|
||||
if s[i] != ESCAPE:
|
||||
return 0
|
||||
if s[i + 1] != PERCENT:
|
||||
return 0
|
||||
length = 4
|
||||
if s[i + 2] == MINUS:
|
||||
i += 1
|
||||
length = 5
|
||||
# 0, 1, 2 or 3 + "B"
|
||||
if 47 < s[i + 2] < 52 and s[i + 3] == CHAR_B:
|
||||
return length
|
||||
except IndexError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
KNOWN_START_SEQUENCES = [b"BPIN", b"BP;IN", b"INPS", b"IN;PS", b"INDF", b"IN;DF"]
|
||||
|
||||
|
||||
def has_known_start_sequence(b: bytes) -> bool:
|
||||
for start_sequence in KNOWN_START_SEQUENCES:
|
||||
if b.startswith(start_sequence):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_hpgl2_entry_point(s: bytes, start: int) -> int:
|
||||
while True:
|
||||
try:
|
||||
index = s.index(b"%", start)
|
||||
except ValueError:
|
||||
return len(s)
|
||||
length = get_enter_hpgl2_mode_command_length(s, index)
|
||||
if length:
|
||||
return index + length
|
||||
start += 2
|
||||
|
||||
|
||||
def hpgl2_commands(s: bytes) -> list[Command]:
|
||||
"""Low level plot file parser, extracts the HPGL/2 from the byte stream `b`.
|
||||
|
||||
.. Important::
|
||||
|
||||
This parser expects the "Enter HPGL/2 mode" escape sequence to recognize
|
||||
HPGL/2 commands. The sequence looks like this: ``[ESC]%1B``, multiple variants
|
||||
of this sequence are supported.
|
||||
|
||||
"""
|
||||
text_terminator = DEFAULT_TEXT_TERMINATOR
|
||||
|
||||
def find_terminator(i: int) -> int:
|
||||
while i < length:
|
||||
c = s[i]
|
||||
if c == QUOTE_CHAR:
|
||||
i = find_mark(i + 1, QUOTE_CHAR)
|
||||
elif (CHAR_A <= c <= CHAR_Z) or c == SEMICOLON or c == ESCAPE:
|
||||
break
|
||||
i += 1
|
||||
return i
|
||||
|
||||
def find_mark(i: int, mark: int) -> int:
|
||||
while i < length and s[i] != mark:
|
||||
i += 1
|
||||
return i + 1
|
||||
|
||||
def append_command(b: bytes) -> None:
|
||||
if b[:2] == b"DT":
|
||||
nonlocal text_terminator
|
||||
if len(b) > 2:
|
||||
text_terminator = b[2]
|
||||
else:
|
||||
text_terminator = DEFAULT_TEXT_TERMINATOR
|
||||
else:
|
||||
commands.append(make_command(b))
|
||||
|
||||
commands: list[Command] = []
|
||||
length = len(s)
|
||||
if has_known_start_sequence(s):
|
||||
index = 0
|
||||
else:
|
||||
index = find_hpgl2_entry_point(s, 0)
|
||||
while index < length:
|
||||
char = s[index]
|
||||
start = index
|
||||
|
||||
if char == ESCAPE:
|
||||
# HPGL/2 does not use escape sequences, whatever this sequence is,
|
||||
# HPGL/2 mode was left. Find next entry point into HPGL/2 mode:
|
||||
index = find_hpgl2_entry_point(s, index)
|
||||
continue
|
||||
|
||||
if char <= 32: # skip all white space and control chars between commands
|
||||
index += 1
|
||||
continue
|
||||
|
||||
index_plus_2 = index + 2
|
||||
if index_plus_2 >= length:
|
||||
append_command(s[index:])
|
||||
break
|
||||
|
||||
command = s[start:index_plus_2]
|
||||
|
||||
if command == b"PE":
|
||||
index = find_mark(index_plus_2, SEMICOLON)
|
||||
index -= 1 # exclude terminator ";" from command args
|
||||
elif command == b"LB":
|
||||
index = find_mark(index_plus_2, text_terminator)
|
||||
# include special terminator in command args,
|
||||
# otherwise the parser is confused
|
||||
else:
|
||||
index = find_terminator(index_plus_2)
|
||||
|
||||
append_command(s[start:index])
|
||||
if index < length and s[index] == SEMICOLON:
|
||||
index += 1
|
||||
return commands
|
||||
|
||||
|
||||
def make_command(cmd: bytes) -> Command:
|
||||
if not cmd:
|
||||
return Command("NOOP", tuple())
|
||||
name = cmd[:2].decode()
|
||||
if name == "PE":
|
||||
args = (bytes([c for c in cmd[2:] if c > 32]),)
|
||||
else:
|
||||
args = tuple(s for s in cmd[2:].split(b",")) # type: ignore
|
||||
return Command(name, args)
|
||||
|
||||
|
||||
def fractional_bits(decimal_places: int) -> int:
|
||||
return round(decimal_places * 3.33)
|
||||
|
||||
|
||||
def pe_encode(value: float, frac_bits: int = 0, base: int = 64) -> bytes:
|
||||
if frac_bits:
|
||||
value *= 1 << frac_bits
|
||||
x = round(value)
|
||||
else:
|
||||
x = round(value)
|
||||
if x >= 0:
|
||||
x *= 2
|
||||
else:
|
||||
x = abs(x) * 2 + 1
|
||||
|
||||
chars = bytearray()
|
||||
while x >= base:
|
||||
x, r = divmod(x, base)
|
||||
chars.append(63 + r)
|
||||
if base == 64:
|
||||
chars.append(191 + x)
|
||||
else:
|
||||
chars.append(95 + x)
|
||||
return bytes(chars)
|
||||
|
||||
|
||||
def pe_decode(
|
||||
s: bytes, frac_bits: int = 0, base=64, start: int = 0
|
||||
) -> tuple[list[float], int]:
|
||||
def _decode():
|
||||
factors.reverse()
|
||||
x = 0
|
||||
for f in factors:
|
||||
x = x * base + f
|
||||
factors.clear()
|
||||
if x & 1:
|
||||
x = -(x - 1)
|
||||
x = x >> 1
|
||||
return x
|
||||
|
||||
n = 1 << frac_bits
|
||||
if base == 64:
|
||||
terminator = 191
|
||||
else:
|
||||
terminator = 95
|
||||
values: list[float] = []
|
||||
factors = []
|
||||
for index in range(start, len(s)):
|
||||
value = s[index]
|
||||
if value < 63:
|
||||
return values, index
|
||||
if value >= terminator:
|
||||
factors.append(value - terminator)
|
||||
x = _decode()
|
||||
if frac_bits:
|
||||
values.append(x / n)
|
||||
else:
|
||||
values.append(float(x))
|
||||
else:
|
||||
factors.append(value - 63)
|
||||
return values, len(s)
|
||||
@@ -0,0 +1,522 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user