refactor: excel parse

This commit is contained in:
Blizzard
2026-04-16 10:01:11 +08:00
parent 680ecc320f
commit f62f95ec02
7941 changed files with 2899112 additions and 0 deletions
@@ -0,0 +1,10 @@
# Copyright (C) 2011-2022, Manfred Moitzi
# License: MIT License
from .mtextsurrogate import MTextSurrogate
from .tablepainter import TablePainter, CustomCell
from .menger_sponge import MengerSponge
from .sierpinski_pyramid import SierpinskyPyramid
from .dimlines import LinearDimension, AngularDimension, ArcDimension, RadialDimension, dimstyles
from .importer import Importer
from .r12writer import r12writer
from .mtxpl import MTextExplode
@@ -0,0 +1,779 @@
# Purpose: read and write AutoCAD CTB files
# Copyright (c) 2010-2023, Manfred Moitzi
# License: MIT License
# IMPORTANT: use only standard 7-Bit ascii code
from __future__ import annotations
from typing import (
Union,
Optional,
BinaryIO,
TextIO,
Iterable,
Iterator,
Any,
)
import os
from abc import abstractmethod
from io import StringIO
from array import array
from struct import pack
import zlib
END_STYLE_BUTT = 0
END_STYLE_SQUARE = 1
END_STYLE_ROUND = 2
END_STYLE_DIAMOND = 3
END_STYLE_OBJECT = 4
JOIN_STYLE_MITER = 0
JOIN_STYLE_BEVEL = 1
JOIN_STYLE_ROUND = 2
JOIN_STYLE_DIAMOND = 3
JOIN_STYLE_OBJECT = 5
FILL_STYLE_SOLID = 64
FILL_STYLE_CHECKERBOARD = 65
FILL_STYLE_CROSSHATCH = 66
FILL_STYLE_DIAMONDS = 67
FILL_STYLE_HORIZONTAL_BARS = 68
FILL_STYLE_SLANT_LEFT = 69
FILL_STYLE_SLANT_RIGHT = 70
FILL_STYLE_SQUARE_DOTS = 71
FILL_STYLE_VERICAL_BARS = 72
FILL_STYLE_OBJECT = 73
DITHERING_ON = 1 # bit coded color_policy
GRAYSCALE_ON = 2 # bit coded color_policy
NAMED_COLOR = 4 # bit coded color_policy
AUTOMATIC = 0
OBJECT_LINEWEIGHT = 0
OBJECT_LINETYPE = 31
OBJECT_COLOR = -1
OBJECT_COLOR2 = -1006632961
STYLE_COUNT = 255
DEFAULT_LINE_WEIGHTS = [
0.00, # 0
0.05, # 1
0.09, # 2
0.10, # 3
0.13, # 4
0.15, # 5
0.18, # 6
0.20, # 7
0.25, # 8
0.30, # 9
0.35, # 10
0.40, # 11
0.45, # 12
0.50, # 13
0.53, # 14
0.60, # 15
0.65, # 16
0.70, # 17
0.80, # 18
0.90, # 19
1.00, # 20
1.06, # 21
1.20, # 22
1.40, # 23
1.58, # 24
2.00, # 25
2.11, # 26
]
# color_type: (thx to Rammi)
# Take color from layer, ignore other bytes.
COLOR_BY_LAYER = 0xC0
# Take color from insertion, ignore other bytes
COLOR_BY_BLOCK = 0xC1
# RGB value, other bytes are R,G,B.
COLOR_RGB = 0xC2
# ACI, AutoCAD color index, other bytes are 0,0,index ???
COLOR_ACI = 0xC3
def color_name(index: int) -> str:
return "Color_%d" % (index + 1)
def get_bool(value: Union[str, bool]) -> bool:
if isinstance(value, str):
upperstr = value.upper()
if upperstr == "TRUE":
value = True
elif upperstr == "FALSE":
value = False
else:
raise ValueError("Unknown bool value '%s'." % str(value))
return value
class PlotStyle:
def __init__(
self,
index: int,
data: Optional[dict] = None,
parent: Optional[PlotStyleTable] = None,
):
data = data or {}
self.parent = parent
self.index = int(index)
self.name = str(data.get("name", color_name(index)))
self.localized_name = str(data.get("localized_name", color_name(index)))
self.description = str(data.get("description", ""))
# do not set _color, _mode_color or _color_policy directly
# use set_color() method, and the properties dithering and grayscale
self._color = int(data.get("color", OBJECT_COLOR))
self._color_type = COLOR_RGB
if self._color != OBJECT_COLOR:
self._mode_color = int(data.get("mode_color", self._color))
self._color_policy = int(data.get("color_policy", DITHERING_ON))
self.physical_pen_number = int(data.get("physical_pen_number", AUTOMATIC))
self.virtual_pen_number = int(data.get("virtual_pen_number", AUTOMATIC))
self.screen = int(data.get("screen", 100))
self.linepattern_size = float(data.get("linepattern_size", 0.5))
self.linetype = int(data.get("linetype", OBJECT_LINETYPE)) # 0 .. 30
self.adaptive_linetype = get_bool(data.get("adaptive_linetype", True))
# lineweight index
self.lineweight = int(data.get("lineweight", OBJECT_LINEWEIGHT))
self.end_style = int(data.get("end_style", END_STYLE_OBJECT))
self.join_style = int(data.get("join_style", JOIN_STYLE_OBJECT))
self.fill_style = int(data.get("fill_style", FILL_STYLE_OBJECT))
@property
def color(self) -> Optional[tuple[int, int, int]]:
"""Get style color as ``(r, g, b)`` tuple or ``None``, if style has
object color.
"""
if self.has_object_color():
return None # object color
else:
return int2color(self._mode_color)[:3]
@color.setter
def color(self, rgb: tuple[int, int, int]) -> None:
"""Set color as RGB values."""
r, g, b = rgb
# when defining a user-color, `mode_color` represents the real
# true_color as (r, g, b) tuple and color_type = COLOR_RGB (0xC2) as
# highest byte, the `color` value calculated for a user-color is not a
# (r, g, b) tuple and has color_type = COLOR_ACI (0xC3) (sometimes), set
# for `color` the same value as for `mode_color`, because AutoCAD
# corrects the `color` value by itself.
self._mode_color = mode_color2int(r, g, b, color_type=self._color_type)
self._color = self._mode_color
@property
def color_type(self):
if self.has_object_color():
return None # object color
else:
return self._color_type
@color_type.setter
def color_type(self, value: int):
self._color_type = value
def set_object_color(self) -> None:
"""Set color to object color."""
self._color = OBJECT_COLOR
self._mode_color = OBJECT_COLOR
def set_lineweight(self, lineweight: float) -> None:
"""Set `lineweight` in millimeters. Use ``0.0`` to set lineweight by
object.
"""
assert self.parent is not None
self.lineweight = self.parent.get_lineweight_index(lineweight)
def get_lineweight(self) -> float:
"""Returns the lineweight in millimeters or `0.0` for use entity
lineweight.
"""
assert self.parent is not None
return self.parent.lineweights[self.lineweight]
def has_object_color(self) -> bool:
"""``True`` if style has object color."""
return self._color in (OBJECT_COLOR, OBJECT_COLOR2)
@property
def aci(self) -> int:
""":ref:`ACI` in range from ``1`` to ``255``. Has no meaning for named
plot styles. (int)
"""
return self.index + 1
@property
def dithering(self) -> bool:
"""Depending on the capabilities of your plotter, dithering approximates
the colors with dot patterns. When this option is ``False``, the colors
are mapped to the nearest color, resulting in a smaller range of
colors when plotting.
Dithering is available only whether you select the objects color or
assign a plot style color.
"""
return bool(self._color_policy & DITHERING_ON)
@dithering.setter
def dithering(self, status: bool) -> None:
if status:
self._color_policy |= DITHERING_ON
else:
self._color_policy &= ~DITHERING_ON
@property
def grayscale(self) -> bool:
"""Plot colors in grayscale. (bool)"""
return bool(self._color_policy & GRAYSCALE_ON)
@grayscale.setter
def grayscale(self, status: bool) -> None:
if status:
self._color_policy |= GRAYSCALE_ON
else:
self._color_policy &= ~GRAYSCALE_ON
@property
def named_color(self) -> bool:
return bool(self._color_policy & NAMED_COLOR)
@named_color.setter
def named_color(self, status: bool) -> None:
if status:
self._color_policy |= NAMED_COLOR
else:
self._color_policy &= ~NAMED_COLOR
def write(self, stream: TextIO) -> None:
"""Write style data to file-like object `stream`."""
index = self.index
stream.write(" %d{\n" % index)
stream.write(' name="%s\n' % self.name)
stream.write(' localized_name="%s\n' % self.localized_name)
stream.write(' description="%s\n' % self.description)
stream.write(" color=%d\n" % self._color)
if self._color != OBJECT_COLOR:
stream.write(" mode_color=%d\n" % self._mode_color)
stream.write(" color_policy=%d\n" % self._color_policy)
stream.write(" physical_pen_number=%d\n" % self.physical_pen_number)
stream.write(" virtual_pen_number=%d\n" % self.virtual_pen_number)
stream.write(" screen=%d\n" % self.screen)
stream.write(" linepattern_size=%s\n" % str(self.linepattern_size))
stream.write(" linetype=%d\n" % self.linetype)
stream.write(
" adaptive_linetype=%s\n" % str(bool(self.adaptive_linetype)).upper()
)
stream.write(" lineweight=%s\n" % str(self.lineweight))
stream.write(" fill_style=%d\n" % self.fill_style)
stream.write(" end_style=%d\n" % self.end_style)
stream.write(" join_style=%d\n" % self.join_style)
stream.write(" }\n")
class PlotStyleTable:
"""PlotStyle container"""
def __init__(
self,
description: str = "",
scale_factor: float = 1.0,
apply_factor: bool = False,
):
self.description = description
self.scale_factor = scale_factor
self.apply_factor = apply_factor
# set custom_lineweight_display_units to 1 for showing lineweight in inch in
# AutoCAD CTB editor window, but lineweight is always defined in mm
self.custom_lineweight_display_units = 0
self.lineweights = array("f", DEFAULT_LINE_WEIGHTS)
def get_lineweight_index(self, lineweight: float) -> int:
"""Get index of `lineweight` in the lineweight table or append
`lineweight` to lineweight table.
"""
try:
return self.lineweights.index(lineweight)
except ValueError:
self.lineweights.append(lineweight)
return len(self.lineweights) - 1
def set_table_lineweight(self, index: int, lineweight: float) -> int:
"""Argument `index` is the lineweight table index, not the :ref:`ACI`.
Args:
index: lineweight table index = :attr:`PlotStyle.lineweight`
lineweight: in millimeters
"""
try:
self.lineweights[index] = lineweight
return index
except IndexError:
self.lineweights.append(lineweight)
return len(self.lineweights) - 1
def get_table_lineweight(self, index: int) -> float:
"""Returns lineweight in millimeters of lineweight table entry `index`.
Args:
index: lineweight table index = :attr:`PlotStyle.lineweight`
Returns:
lineweight in mm or ``0.0`` for use entity lineweight
"""
return self.lineweights[index]
def save(self, filename: str | os.PathLike) -> None:
"""Save CTB or STB file as `filename` to the file system."""
with open(filename, "wb") as stream:
self.write(stream)
def write(self, stream: BinaryIO) -> None:
"""Compress and write the CTB or STB file to binary `stream`."""
memfile = StringIO()
self.write_content(memfile)
memfile.write(chr(0)) # end of file
body = memfile.getvalue()
memfile.close()
_compress(stream, body)
@abstractmethod
def write_content(self, stream: TextIO) -> None:
pass
def _write_lineweights(self, stream: TextIO) -> None:
"""Write custom lineweight table to text `stream`."""
stream.write("custom_lineweight_table{\n")
for index, weight in enumerate(self.lineweights):
stream.write(" %d=%.2f\n" % (index, weight))
stream.write("}\n")
def parse(self, text: str) -> None:
"""Parse plot styles from CTB string `text`."""
def set_lineweights(lineweights):
if lineweights is None:
return
self.lineweights = array("f", [0.0] * len(lineweights))
for key, value in lineweights.items():
self.lineweights[int(key)] = float(value)
parser = PlotStyleFileParser(text)
self.description = parser.get("description", "")
self.scale_factor = float(parser.get("scale_factor", 1.0))
self.apply_factor = get_bool(parser.get("apply_factor", True))
self.custom_lineweight_display_units = int(
parser.get("custom_lineweight_display_units", 0)
)
set_lineweights(parser.get("custom_lineweight_table", None))
self.load_styles(parser.get("plot_style", {}))
@abstractmethod
def load_styles(self, styles):
pass
class ColorDependentPlotStyles(PlotStyleTable):
def __init__(
self,
description: str = "",
scale_factor: float = 1.0,
apply_factor: bool = False,
):
super().__init__(description, scale_factor, apply_factor)
self._styles: list[PlotStyle] = [
PlotStyle(index, parent=self) for index in range(STYLE_COUNT)
]
self._styles.insert(
0, PlotStyle(256)
) # 1-based array: insert dummy value for index 0
def __getitem__(self, aci: int) -> PlotStyle:
"""Returns :class:`PlotStyle` for :ref:`ACI` `aci`."""
if 0 < aci < 256:
return self._styles[aci]
else:
raise IndexError(aci)
def __setitem__(self, aci: int, style: PlotStyle):
"""Set plot `style` for `aci`."""
if 0 < aci < 256:
style.parent = self
self._styles[aci] = style
else:
raise IndexError(aci)
def __iter__(self):
"""Iterable of all plot styles."""
return iter(self._styles[1:])
def new_style(self, aci: int, data: Optional[dict] = None) -> PlotStyle:
"""Set `aci` to new attributes defined by `data` dict.
Args:
aci: :ref:`ACI`
data: ``dict`` of :class:`PlotStyle` attributes: description, color,
physical_pen_number, virtual_pen_number, screen,
linepattern_size, linetype, adaptive_linetype,
lineweight, end_style, join_style, fill_style
"""
# ctb table index = aci - 1
# ctb table starts with index 0, where aci == 0 means BYBLOCK
style = PlotStyle(index=aci - 1, data=data)
style.color_type = COLOR_RGB
self[aci] = style
return style
def get_lineweight(self, aci: int):
"""Returns the assigned lineweight for :class:`PlotStyle` `aci` in
millimeter.
"""
style = self[aci]
lineweight = style.get_lineweight()
if lineweight == 0.0:
return None
else:
return lineweight
def write_content(self, stream: TextIO) -> None:
"""Write the CTB-file to text `stream`."""
self._write_header(stream)
self._write_aci_table(stream)
self._write_plot_styles(stream)
self._write_lineweights(stream)
def _write_header(self, stream: TextIO) -> None:
"""Write header values of CTB-file to text `stream`."""
stream.write('description="%s\n' % self.description)
stream.write("aci_table_available=TRUE\n")
stream.write("scale_factor=%.1f\n" % self.scale_factor)
stream.write("apply_factor=%s\n" % str(self.apply_factor).upper())
stream.write(
"custom_lineweight_display_units=%s\n"
% str(self.custom_lineweight_display_units)
)
def _write_aci_table(self, stream: TextIO) -> None:
"""Write AutoCAD Color Index table to text `stream`."""
stream.write("aci_table{\n")
for style in self:
index = style.index
stream.write(' %d="%s\n' % (index, color_name(index)))
stream.write("}\n")
def _write_plot_styles(self, stream: TextIO) -> None:
"""Write user styles to text `stream`."""
stream.write("plot_style{\n")
for style in self:
style.write(stream)
stream.write("}\n")
def load_styles(self, styles):
for index, style in styles.items():
index = int(index)
style = PlotStyle(index, style)
style.color_type = COLOR_RGB
aci = index + 1
self[aci] = style
class NamedPlotStyles(PlotStyleTable):
def __init__(
self,
description: str = "",
scale_factor: float = 1.0,
apply_factor: bool = False,
):
super().__init__(description, scale_factor, apply_factor)
normal = PlotStyle(
0,
data={
"name": "Normal",
"localized_name": "Normal",
},
)
self._styles: dict[str, PlotStyle] = {"Normal": normal}
def __iter__(self) -> Iterable[str]:
"""Iterable of all plot style names."""
return self.keys()
def __getitem__(self, name: str) -> PlotStyle:
"""Returns :class:`PlotStyle` by `name`."""
return self._styles[name]
def __delitem__(self, name: str) -> None:
"""Delete plot style `name`. Plot style ``'Normal'`` is not deletable."""
if name != "Normal":
del self._styles[name]
else:
raise ValueError("Can't delete plot style 'Normal'. ")
def keys(self) -> Iterable[str]:
"""Iterable of all plot style names."""
keys = set(self._styles.keys())
keys.discard("Normal")
result = ["Normal"]
result.extend(sorted(keys))
return iter(result)
def items(self) -> Iterator[tuple[str, PlotStyle]]:
"""Iterable of all plot styles as (``name``, class:`PlotStyle`) tuples."""
for key in self.keys():
yield key, self._styles[key]
def values(self) -> Iterable[PlotStyle]:
"""Iterable of all class:`PlotStyle` objects."""
for key, value in self.items():
yield value
def new_style(
self,
name: str,
data: Optional[dict] = None,
localized_name: Optional[str] = None,
) -> PlotStyle:
"""Create new class:`PlotStyle` `name` by attribute dict `data`, replaces
existing class:`PlotStyle` objects.
Args:
name: plot style name
localized_name: name shown in plot style editor, uses `name` if ``None``
data: ``dict`` of :class:`PlotStyle` attributes: description, color,
physical_pen_number, virtual_pen_number, screen,
linepattern_size, linetype, adaptive_linetype, lineweight,
end_style, join_style, fill_style
"""
if name.lower() == "Normal":
raise ValueError("Can't replace or modify plot style 'Normal'. ")
data = data or {}
data["name"] = name
data["localized_name"] = localized_name or name
index = len(self._styles)
style = PlotStyle(index=index, data=data, parent=self)
style.color_type = COLOR_ACI
style.named_color = True
self._styles[name] = style
return style
def get_lineweight(self, name: str):
"""Returns the assigned lineweight for :class:`PlotStyle` `name` in
millimeter.
"""
style = self[name]
lineweight = style.get_lineweight()
if lineweight == 0.0:
return None
else:
return lineweight
def write_content(self, stream: TextIO) -> None:
"""Write the STB-file to text `stream`."""
self._write_header(stream)
self._write_plot_styles(stream)
self._write_lineweights(stream)
def _write_header(self, stream: TextIO) -> None:
"""Write header values of CTB-file to text `stream`."""
stream.write('description="%s\n' % self.description)
stream.write("aci_table_available=FALSE\n")
stream.write("scale_factor=%.1f\n" % self.scale_factor)
stream.write("apply_factor=%s\n" % str(self.apply_factor).upper())
stream.write(
"custom_lineweight_display_units=%s\n"
% str(self.custom_lineweight_display_units)
)
def _write_plot_styles(self, stream: TextIO) -> None:
"""Write user styles to text `stream`."""
stream.write("plot_style{\n")
for index, style in enumerate(self.values()):
style.index = index
style.write(stream)
stream.write("}\n")
def load_styles(self, styles):
for index, style in styles.items():
index = int(index)
style = PlotStyle(index, style)
style.color_type = COLOR_ACI
self._styles[style.name] = style
def _read_ctb(stream: BinaryIO) -> ColorDependentPlotStyles:
"""Read a CTB-file from binary `stream`."""
content: bytes = _decompress(stream)
styles = ColorDependentPlotStyles()
styles.parse(content.decode())
return styles
def _read_stb(stream: BinaryIO) -> NamedPlotStyles:
"""Read a STB-file from binary `stream`."""
content: bytes = _decompress(stream)
styles = NamedPlotStyles()
styles.parse(content.decode())
return styles
def load(
filename: str | os.PathLike,
) -> Union[ColorDependentPlotStyles, NamedPlotStyles]:
"""Load the CTB or STB file `filename` from file system."""
filename = str(filename)
with open(filename, "rb") as stream:
if filename.lower().endswith(".ctb"):
return _read_ctb(stream)
elif filename.lower().endswith(".stb"):
return _read_stb(stream)
else:
raise ValueError('Invalid file type: "{}"'.format(filename))
def new_ctb() -> ColorDependentPlotStyles:
"""Create a new CTB file."""
return ColorDependentPlotStyles()
def new_stb() -> NamedPlotStyles:
"""Create a new STB file."""
return NamedPlotStyles()
def _decompress(stream: BinaryIO) -> bytes:
"""Read and decompress the file content from binray `stream`."""
content = stream.read()
data = zlib.decompress(content[60:]) # type: bytes
return data[:-1] # truncate trailing \nul
def _compress(stream: BinaryIO, content: str):
"""Compress `content` and write to binary `stream`."""
comp_body = zlib.compress(content.encode())
adler_chksum = zlib.adler32(comp_body)
stream.write(b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec")
stream.write(pack("LLL", adler_chksum, len(content), len(comp_body)))
stream.write(comp_body)
class PlotStyleFileParser:
"""A very simple CTB/STB file parser. CTB/STB files are created by
applications, so the file structure should be correct in the most cases.
"""
def __init__(self, text: str):
self.data = {}
for element, value in PlotStyleFileParser.iteritems(text):
self.data[element] = value
@staticmethod
def iteritems(text: str):
"""Iterate over all first level (start at col 0) elements."""
line_index = 0
def get_name() -> str:
"""Get element name of line <line_index>."""
line = lines[line_index]
if line.endswith("{"): # start of a list like 'plot_style{'
name = line[:-1]
else: # simple name=value line
name = line.split("=", 1)[0]
return name.strip()
def get_mapping() -> dict:
"""Get mapping of elements enclosed by { }.
e. g. lineweights, plot_styles, aci_table
"""
def end_of_list():
return lines[line_index].endswith("}")
nonlocal line_index
data = dict()
while not end_of_list():
name = get_name()
value = get_value() # get value or sub-list
data[name] = value
line_index += 1
return data # skip '}' - end of list
def get_value() -> Union[str, dict]:
"""Get value of line <line_index> or the list that starts in line
<line_index>.
"""
nonlocal line_index
line = lines[line_index]
if line.endswith("{"): # start of a list
line_index += 1
return get_mapping()
else: # it's a simple name=value line
value: str = line.split("=", 1)[1]
value = sanitized_value(value)
line_index += 1
return value
def skip_empty_lines():
nonlocal line_index
while line_index < len(lines) and len(lines[line_index]) == 0:
line_index += 1
lines = text.split("\n")
while line_index < len(lines):
name = get_name()
value = get_value()
yield name, value
skip_empty_lines()
def get(self, name: str, default: Any) -> Any:
return self.data.get(name, default)
def sanitized_value(value: str) -> str:
value = value.strip()
if value.startswith('"'): # strings: <name>="string
return value[1:]
# remove unknown appendix like this: "0.0076200000000 (+7.Z+"8V?S_LC )"
# the pattern is "<float|int> (<some data>)", see issue #1069
if value.endswith(")"):
return value.split(" ")[0]
return value
def int2color(color: int) -> tuple[int, int, int, int]:
"""Convert color integer value from CTB-file to ``(r, g, b, color_type)
tuple.
"""
# Take color from layer, ignore other bytes.
color_type = (color & 0xFF000000) >> 24
red = (color & 0xFF0000) >> 16
green = (color & 0xFF00) >> 8
blue = color & 0xFF
return red, green, blue, color_type
def mode_color2int(red: int, green: int, blue: int, color_type=COLOR_RGB) -> int:
"""Convert mode_color (r, g, b, color_type) tuple to integer."""
return -color2int(red, green, blue, color_type)
def color2int(red: int, green: int, blue: int, color_type: int) -> int:
"""Convert color (r, g, b, color_type) to integer."""
return -((color_type << 24) + (red << 16) + (green << 8) + blue) & 0xFFFFFFFF
@@ -0,0 +1,2 @@
# Copyright (c) 2022, Manfred Moitzi
# License: MIT License
@@ -0,0 +1,297 @@
# Copyright (c) 2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterator, Iterable, Optional
import ezdxf
from ezdxf.addons.xqt import (
QtWidgets,
QtGui,
QAction,
QMessageBox,
QFileDialog,
Qt,
QModelIndex,
)
from ezdxf.document import Drawing
from ezdxf.entities import Body
from ezdxf.lldxf.const import DXFStructureError
from .data import AcisData, BinaryAcisData, TextAcisData
APP_NAME = "ACIS Structure Browser"
BROWSER_WIDTH = 1024
BROWSER_HEIGHT = 768
SELECTOR_WIDTH_FACTOR = 0.20
FONT_FAMILY = "monospaced"
def make_font():
font = QtGui.QFont(FONT_FAMILY)
font.setStyleHint(QtGui.QFont.Monospace)
return font
class AcisStructureBrowser(QtWidgets.QMainWindow):
def __init__(
self,
filename: str = "",
handle: str = "",
):
super().__init__()
self.doc: Optional[Drawing] = None
self.acis_entities: list[AcisData] = []
self.current_acis_entity = AcisData()
self.entity_selector = self.make_entity_selector()
self.acis_content_viewer = self.make_content_viewer()
self.statusbar = QtWidgets.QStatusBar(self)
self.setup_actions()
self.setup_menu()
if filename:
self.load_dxf(filename)
else:
self.setWindowTitle(APP_NAME)
self.setStatusBar(self.statusbar)
self.setCentralWidget(self.make_central_widget())
self.resize(BROWSER_WIDTH, BROWSER_HEIGHT)
self.connect_slots()
if handle:
try:
int(handle, 16)
except ValueError:
msg = f"Given handle is not a hex value: '{handle}'"
self.statusbar.showMessage(msg)
print(msg)
else:
if not self.goto_handle(handle):
msg = f"Handle '{handle}' not found."
self.statusbar.showMessage(msg)
print(msg)
def make_entity_selector(self):
return QtWidgets.QListWidget(self)
def make_content_viewer(self):
viewer = QtWidgets.QPlainTextEdit(self)
viewer.setReadOnly(True)
viewer.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
return viewer
def make_central_widget(self):
container = QtWidgets.QSplitter(Qt.Horizontal)
container.addWidget(self.entity_selector)
container.addWidget(self.acis_content_viewer)
selector_width = int(BROWSER_WIDTH * SELECTOR_WIDTH_FACTOR)
entity_view_width = BROWSER_WIDTH - selector_width
container.setSizes([selector_width, entity_view_width])
container.setCollapsible(0, False)
container.setCollapsible(1, False)
return container
def connect_slots(self):
self.entity_selector.clicked.connect(self.acis_entity_activated)
self.entity_selector.activated.connect(self.acis_entity_activated)
# noinspection PyAttributeOutsideInit
def setup_actions(self):
self._open_action = self.make_action(
"&Open DXF File...", self.open_dxf, shortcut="Ctrl+O"
)
self._reload_action = self.make_action(
"Reload DXF File",
self.reload_dxf,
shortcut="Ctrl+R",
)
self._export_entity_action = self.make_action(
"&Export Current Entity View...",
self.export_entity,
shortcut="Ctrl+E",
)
self._export_raw_data_action = self.make_action(
"&Export Raw SAT/SAB Data...",
self.export_raw_entity,
shortcut="Ctrl+W",
)
self._quit_action = self.make_action(
"&Quit", self.close, shortcut="Ctrl+Q"
)
def make_action(
self,
name,
slot,
*,
shortcut: str = "",
tip: str = "",
) -> QAction:
action = QAction(name, self)
if shortcut:
action.setShortcut(shortcut)
if tip:
action.setToolTip(tip)
action.triggered.connect(slot)
return action
def setup_menu(self):
menu = self.menuBar()
file_menu = menu.addMenu("&File")
file_menu.addAction(self._open_action)
file_menu.addAction(self._reload_action)
file_menu.addSeparator()
file_menu.addAction(self._export_entity_action)
file_menu.addAction(self._export_raw_data_action)
file_menu.addSeparator()
file_menu.addAction(self._quit_action)
def open_dxf(self):
path, _ = QtWidgets.QFileDialog.getOpenFileName(
self,
caption="Select DXF file",
filter="DXF Documents (*.dxf *.DXF)",
)
if path:
self.load_dxf(path)
def load_dxf(self, path: str):
try:
doc = ezdxf.readfile(path)
except IOError as e:
QMessageBox.critical(self, "Loading Error", str(e))
return
except DXFStructureError as e:
QMessageBox.critical(
self,
"DXF Structure Error",
f'Invalid DXF file "{path}": {str(e)}',
)
return
entities = list(get_acis_entities(doc))
if len(entities):
self.doc = doc
self.set_acis_entities(entities)
self.update_title(path)
self.statusbar.showMessage(self.make_loading_message())
else:
msg = f"DXF file '{path}' contains no ACIS data"
QMessageBox.information(self, "Loading Error", msg) # type: ignore
def make_loading_message(self) -> str:
assert self.doc is not None
dxfversion = self.doc.dxfversion
acis_type = "SAB" if dxfversion >= "AC1027" else "SAT"
return f"Loaded DXF file has version {self.doc.acad_release}/{dxfversion}" \
f" and contains {acis_type} data"
def set_acis_entities(self, entities: list[AcisData]):
self.acis_entities = entities
self.update_entity_selector(entities)
self.set_current_acis_entity(entities[0])
def reload_dxf(self):
try:
index = self.acis_entities.index(self.current_acis_entity)
except IndexError:
index = -1
self.load_dxf(self.doc.filename)
if index > 0:
self.set_current_acis_entity(self.acis_entities[index])
def export_entity(self):
dxf_entity = self.get_current_dxf_entity()
if dxf_entity is None:
return
path, _ = QFileDialog.getSaveFileName(
self,
caption="Export Current Entity View",
dir=f"{dxf_entity.dxftype()}-{dxf_entity.dxf.handle}.txt",
filter="Text Files (*.txt *.TXT)",
)
if path:
write_data(self.current_acis_entity, path)
def export_raw_entity(self):
dxf_entity = self.get_current_dxf_entity()
if dxf_entity is None:
return
filename = f"{dxf_entity.dxftype()}-{dxf_entity.dxf.handle}"
sab = dxf_entity.has_binary_data
if sab:
filter_ = "Standard ACIS Binary Files (*.sab *.SAB)"
filename += ".sab"
else:
filter_ = "Standard ACIS Text Files (*.sat *.SAT)"
filename += ".sat"
path, _ = QFileDialog.getSaveFileName(
self,
caption="Export ACIS Raw Data",
dir=filename,
filter=filter_,
)
if path:
if sab:
with open(path, "wb") as fp:
fp.write(dxf_entity.sab)
else:
with open(path, "wt") as fp:
fp.write("\n".join(dxf_entity.sat))
def get_current_dxf_entity(self) -> Optional[Body]:
current = self.current_acis_entity
if not current.handle or self.doc is None:
return None
return self.doc.entitydb.get(current.handle) # type: ignore
def update_title(self, path: str):
self.setWindowTitle(f"{APP_NAME} - {path}")
def acis_entity_activated(self, index: QModelIndex):
if len(self.acis_entities) == 0:
return
try:
self.set_current_acis_entity(self.acis_entities[index.row()])
except IndexError:
self.set_current_acis_entity(self.acis_entities[0])
def set_current_acis_entity(self, entity: AcisData):
if entity:
self.current_acis_entity = entity
self.update_acis_content_viewer(entity)
def update_acis_content_viewer(self, entity: AcisData):
viewer = self.acis_content_viewer
viewer.clear()
viewer.setPlainText("\n".join(entity.lines))
def update_entity_selector(self, entities: Iterable[AcisData]):
viewer = self.entity_selector
viewer.clear()
viewer.addItems([e.name for e in entities])
def goto_handle(self, handle: str) -> bool:
for entity in self.acis_entities:
if entity.handle == handle:
self.set_current_acis_entity(entity)
return True
return False
def get_acis_entities(doc: Drawing) -> Iterator[AcisData]:
for e in doc.entitydb.values():
if isinstance(e, Body):
handle = e.dxf.handle
name = f"<{handle}> {e.dxftype()}"
if e.has_binary_data:
yield BinaryAcisData(e.sab, name, handle)
else:
yield TextAcisData(e.sat, name, handle)
def write_data(entity: AcisData, path: str):
try:
with open(path, "wt") as fp:
fp.write("\n".join(entity.lines))
except IOError:
pass
@@ -0,0 +1,60 @@
# Copyright (c) 2022-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterator, Sequence
from ezdxf.acis.sat import parse_sat, SatEntity
from ezdxf.acis.sab import parse_sab, SabEntity
class AcisData:
def __init__(self, name: str = "unknown", handle: str = ""):
self.lines: list[str] = []
self.name: str = name
self.handle: str = handle
class BinaryAcisData(AcisData):
def __init__(self, data: bytes, name: str, handle: str):
super().__init__(name, handle)
self.lines = list(make_sab_records(data))
class TextAcisData(AcisData):
def __init__(self, data: Sequence[str], name: str, handle: str):
super().__init__(name, handle)
self.lines = list(make_sat_records(data))
def ptr_str(e):
return "~" if e.is_null_ptr else str(e)
def make_sat_records(data: Sequence[str]) -> Iterator[str]:
builder = parse_sat(data)
yield from builder.header.dumps()
builder.reset_ids()
for entity in builder.entities:
content = [str(entity)]
content.append(ptr_str(entity.attributes))
for field in entity.data:
if isinstance(field, SatEntity):
content.append(ptr_str(field))
else:
content.append(field)
yield " ".join(content)
def make_sab_records(data: bytes) -> Iterator[str]:
builder = parse_sab(data)
yield from builder.header.dumps()
builder.reset_ids()
for entity in builder.entities:
content = [str(entity)]
content.append(ptr_str(entity.attributes))
for tag in entity.data:
if isinstance(tag.value, SabEntity):
content.append(ptr_str(tag.value))
else:
content.append(f"{tag.value}<{tag.tag}>")
yield " ".join(content)
@@ -0,0 +1,716 @@
# Source package: "py3dbp" hosted on PyPI
# (c) Enzo Ruiz Pelaez
# https://github.com/enzoruiz/3dbinpacking
# License: MIT License
# Credits:
# - https://github.com/enzoruiz/3dbinpacking/blob/master/erick_dube_507-034.pdf
# - https://github.com/gedex/bp3d - implementation in Go
# - https://github.com/bom-d-van/binpacking - implementation in Go
#
# ezdxf add-on:
# License: MIT License
# (c) 2022, Manfred Moitzi:
# - refactoring
# - type annotations
# - adaptations:
# - removing Decimal class usage
# - utilizing ezdxf.math.BoundingBox for intersection checks
# - removed non-distributing mode; copy packer and use different bins for each copy
# - additions:
# - Item.get_transformation()
# - shuffle_pack()
# - pack_item_subset()
# - DXF exporter for debugging
from __future__ import annotations
from typing import (
Iterable,
TYPE_CHECKING,
TypeVar,
Optional,
)
from enum import Enum, auto
import copy
import math
import random
from ezdxf.enums import TextEntityAlignment
from ezdxf.math import (
Vec2,
Vec3,
UVec,
BoundingBox,
BoundingBox2d,
AbstractBoundingBox,
Matrix44,
)
from . import genetic_algorithm as ga
if TYPE_CHECKING:
from ezdxf.eztypes import GenericLayoutType
__all__ = [
"Item",
"FlatItem",
"Box", # contains Item
"Envelope", # contains FlatItem
"AbstractPacker",
"Packer",
"FlatPacker",
"RotationType",
"PickStrategy",
"shuffle_pack",
"pack_item_subset",
"export_dxf",
]
UNLIMITED_WEIGHT = 1e99
T = TypeVar("T")
PI_2 = math.pi / 2
class RotationType(Enum):
"""Rotation type of an item:
- W = width
- H = height
- D = depth
"""
WHD = auto()
HWD = auto()
HDW = auto()
DHW = auto()
DWH = auto()
WDH = auto()
class Axis(Enum):
WIDTH = auto()
HEIGHT = auto()
DEPTH = auto()
class PickStrategy(Enum):
"""Order of how to pick items for placement."""
SMALLER_FIRST = auto()
BIGGER_FIRST = auto()
SHUFFLE = auto()
START_POSITION: tuple[float, float, float] = (0, 0, 0)
class Item:
"""3D container item."""
def __init__(
self,
payload,
width: float,
height: float,
depth: float,
weight: float = 0.0,
):
self.payload = payload # arbitrary associated Python object
self.width = float(width)
self.height = float(height)
self.depth = float(depth)
self.weight = float(weight)
self._rotation_type = RotationType.WHD
self._position = START_POSITION
self._bbox: Optional[AbstractBoundingBox] = None
def __str__(self):
return (
f"{str(self.payload)}({self.width}x{self.height}x{self.depth}, "
f"weight: {self.weight}) pos({str(self.position)}) "
f"rt({self.rotation_type}) vol({self.get_volume()})"
)
def copy(self):
"""Returns a copy, all copies have a reference to the same payload
object.
"""
return copy.copy(self) # shallow copy
@property
def bbox(self) -> AbstractBoundingBox:
if self._bbox is None:
self._update_bbox()
return self._bbox # type: ignore
def _update_bbox(self) -> None:
v1 = Vec3(self._position)
self._bbox = BoundingBox([v1, v1 + Vec3(self.get_dimension())])
def _taint(self):
self._bbox = None
@property
def rotation_type(self) -> RotationType:
return self._rotation_type
@rotation_type.setter
def rotation_type(self, value: RotationType) -> None:
self._rotation_type = value
self._taint()
@property
def position(self) -> tuple[float, float, float]:
"""Returns the position of then lower left corner of the item in the
container, the lower left corner is the origin (0, 0, 0).
"""
return self._position
@position.setter
def position(self, value: tuple[float, float, float]) -> None:
self._position = value
self._taint()
def get_volume(self) -> float:
"""Returns the volume of the item."""
return self.width * self.height * self.depth
def get_dimension(self) -> tuple[float, float, float]:
"""Returns the item dimension according the :attr:`rotation_type`."""
rt = self.rotation_type
if rt == RotationType.WHD:
return self.width, self.height, self.depth
elif rt == RotationType.HWD:
return self.height, self.width, self.depth
elif rt == RotationType.HDW:
return self.height, self.depth, self.width
elif rt == RotationType.DHW:
return self.depth, self.height, self.width
elif rt == RotationType.DWH:
return self.depth, self.width, self.height
elif rt == RotationType.WDH:
return self.width, self.depth, self.height
raise ValueError(rt)
def get_transformation(self) -> Matrix44:
"""Returns the transformation matrix to transform the source entity
located with the minimum extension corner of its bounding box in
(0, 0, 0) to the final location including the required rotation.
"""
x, y, z = self.position
rt = self.rotation_type
if rt == RotationType.WHD: # width, height, depth
return Matrix44.translate(x, y, z)
if rt == RotationType.HWD: # height, width, depth
return Matrix44.z_rotate(PI_2) @ Matrix44.translate(
x + self.height, y, z
)
if rt == RotationType.HDW: # height, depth, width
return Matrix44.xyz_rotate(PI_2, 0, PI_2) @ Matrix44.translate(
x + self.height, y + self.depth, z
)
if rt == RotationType.DHW: # depth, height, width
return Matrix44.y_rotate(-PI_2) @ Matrix44.translate(
x + self.depth, y, z
)
if rt == RotationType.DWH: # depth, width, height
return Matrix44.xyz_rotate(0, PI_2, PI_2) @ Matrix44.translate(
x, y, z
)
if rt == RotationType.WDH: # width, depth, height
return Matrix44.x_rotate(PI_2) @ Matrix44.translate(
x, y + self.depth, z
)
raise TypeError(rt)
class FlatItem(Item):
"""2D container item, inherited from :class:`Item`. Has a default depth of
1.0.
"""
def __init__(
self,
payload,
width: float,
height: float,
weight: float = 0.0,
):
super().__init__(payload, width, height, 1.0, weight)
def _update_bbox(self) -> None:
v1 = Vec2(self._position)
self._bbox = BoundingBox2d([v1, v1 + Vec2(self.get_dimension())])
def __str__(self):
return (
f"{str(self.payload)}({self.width}x{self.height}, "
f"weight: {self.weight}) pos({str(self.position)}) "
f"rt({self.rotation_type}) area({self.get_volume()})"
)
class Bin:
def __init__(
self,
name,
width: float,
height: float,
depth: float,
max_weight: float = UNLIMITED_WEIGHT,
):
self.name = name
self.width = float(width)
if self.width <= 0.0:
raise ValueError("invalid width")
self.height = float(height)
if self.height <= 0.0:
raise ValueError("invalid height")
self.depth = float(depth)
if self.depth <= 0.0:
raise ValueError("invalid depth")
self.max_weight = float(max_weight)
self.items: list[Item] = []
def __len__(self):
return len(self.items)
def __iter__(self):
return iter(self.items)
def copy(self):
"""Returns a copy."""
box = copy.copy(self) # shallow copy
box.items = list(self.items)
return box
def reset(self):
"""Reset the container to empty state."""
self.items.clear()
@property
def is_empty(self) -> bool:
return not len(self.items)
def __str__(self) -> str:
return (
f"{str(self.name)}({self.width:.3f}x{self.height:.3f}x{self.depth:.3f}, "
f"max_weight:{self.max_weight}) "
f"vol({self.get_capacity():.3f})"
)
def put_item(self, item: Item, pivot: tuple[float, float, float]) -> bool:
valid_item_position = item.position
item.position = pivot
x, y, z = pivot
# Try all possible rotations:
for rotation_type in self.rotations():
item.rotation_type = rotation_type
w, h, d = item.get_dimension()
if self.width < x + w or self.height < y + h or self.depth < z + d:
continue
# new item fits inside the box at he current location and rotation:
item_bbox = item.bbox
if (
not any(item_bbox.has_intersection(i.bbox) for i in self.items)
and self.get_total_weight() + item.weight <= self.max_weight
):
self.items.append(item)
return True
item.position = valid_item_position
return False
def get_capacity(self) -> float:
"""Returns the maximum fill volume of the bin."""
return self.width * self.height * self.depth
def get_total_weight(self) -> float:
"""Returns the total weight of all fitted items."""
return sum(item.weight for item in self.items)
def get_total_volume(self) -> float:
"""Returns the total volume of all fitted items."""
return sum(item.get_volume() for item in self.items)
def get_fill_ratio(self) -> float:
"""Return the fill ratio."""
try:
return self.get_total_volume() / self.get_capacity()
except ZeroDivisionError:
return 0.0
def rotations(self) -> Iterable[RotationType]:
return RotationType
class Box(Bin):
"""3D container inherited from :class:`Bin`."""
pass
class Envelope(Bin):
"""2D container inherited from :class:`Bin`."""
def __init__(
self,
name,
width: float,
height: float,
max_weight: float = UNLIMITED_WEIGHT,
):
super().__init__(name, width, height, 1.0, max_weight)
def __str__(self) -> str:
return (
f"{str(self.name)}({self.width:.3f}x{self.height:.3f}, "
f"max_weight:{self.max_weight}) "
f"area({self.get_capacity():.3f})"
)
def rotations(self) -> Iterable[RotationType]:
return RotationType.WHD, RotationType.HWD
def _smaller_first(bins: list, items: list) -> None:
# SMALLER_FIRST is often very bad! Especially for many in small
# amounts increasing sizes.
bins.sort(key=lambda b: b.get_capacity())
items.sort(key=lambda i: i.get_volume())
def _bigger_first(bins: list, items: list) -> None:
# BIGGER_FIRST is the best strategy
bins.sort(key=lambda b: b.get_capacity(), reverse=True)
items.sort(key=lambda i: i.get_volume(), reverse=True)
def _shuffle(bins: list, items: list) -> None:
# Better as SMALLER_FIRST
random.shuffle(bins)
random.shuffle(items)
PICK_STRATEGY = {
PickStrategy.SMALLER_FIRST: _smaller_first,
PickStrategy.BIGGER_FIRST: _bigger_first,
PickStrategy.SHUFFLE: _shuffle,
}
class AbstractPacker:
def __init__(self) -> None:
self.bins: list[Bin] = []
self.items: list[Item] = []
self._init_state = True
def copy(self):
"""Copy packer in init state to apply different pack strategies."""
if self.is_packed:
raise TypeError("cannot copy packed state")
if not all(box.is_empty for box in self.bins):
raise TypeError("bins contain data in unpacked state")
packer = self.__class__()
packer.bins = [box.copy() for box in self.bins]
packer.items = [item.copy() for item in self.items]
return packer
@property
def is_packed(self) -> bool:
"""Returns ``True`` if packer is packed, each packer can only be used
once.
"""
return not self._init_state
@property
def unfitted_items(self) -> list[Item]: # just an alias
"""Returns the unfitted items."""
return self.items
def __str__(self) -> str:
fill = ""
if self.is_packed:
fill = f", fill ratio: {self.get_fill_ratio()}"
return f"{self.__class__.__name__}, {len(self.bins)} bins{fill}"
def append_bin(self, box: Bin) -> None:
"""Append a container."""
if self.is_packed:
raise TypeError("cannot append bins to packed state")
if not box.is_empty:
raise TypeError("cannot append bins with content")
self.bins.append(box)
def append_item(self, item: Item) -> None:
"""Append a item."""
if self.is_packed:
raise TypeError("cannot append items to packed state")
self.items.append(item)
def get_fill_ratio(self) -> float:
"""Return the fill ratio of all bins."""
total_capacity = self.get_capacity()
if total_capacity == 0.0:
return 0.0
return self.get_total_volume() / total_capacity
def get_capacity(self) -> float:
"""Returns the maximum fill volume of all bins."""
return sum(box.get_capacity() for box in self.bins)
def get_total_weight(self) -> float:
"""Returns the total weight of all fitted items in all bins."""
return sum(box.get_total_weight() for box in self.bins)
def get_total_volume(self) -> float:
"""Returns the total volume of all fitted items in all bins."""
return sum(box.get_total_volume() for box in self.bins)
def get_unfitted_volume(self) -> float:
"""Returns the total volume of all unfitted items."""
return sum(item.get_volume() for item in self.items)
def pack(self, pick=PickStrategy.BIGGER_FIRST) -> None:
"""Pack items into bins. Distributes all items across all bins."""
PICK_STRATEGY[pick](self.bins, self.items)
# items are removed from self.items while packing!
self._pack(self.bins, list(self.items))
# unfitted items remain in self.items
def _pack(self, bins: Iterable[Bin], items: Iterable[Item]) -> None:
"""Pack items into bins, removes packed items from self.items!"""
self._init_state = False
for box in bins:
for item in items:
if self.pack_to_bin(box, item):
self.items.remove(item)
# unfitted items remain in self.items
def pack_to_bin(self, box: Bin, item: Item) -> bool:
if not box.items:
return box.put_item(item, START_POSITION)
for axis in self._axis():
for placed_item in box.items:
w, h, d = placed_item.get_dimension()
x, y, z = placed_item.position
if axis == Axis.WIDTH:
pivot = (x + w, y, z) # new item right of the placed item
elif axis == Axis.HEIGHT:
pivot = (x, y + h, z) # new item above of the placed item
elif axis == Axis.DEPTH:
pivot = (x, y, z + d) # new item on top of the placed item
else:
raise TypeError(axis)
if box.put_item(item, pivot):
return True
return False
@staticmethod
def _axis() -> Iterable[Axis]:
return Axis
def shuffle_pack(packer: AbstractPacker, attempts: int) -> AbstractPacker:
"""Random shuffle packing. Returns a new packer with the best packing result,
the input packer is unchanged.
"""
if attempts < 1:
raise ValueError("expected attempts >= 1")
best_ratio = 0.0
best_packer = packer
for _ in range(attempts):
new_packer = packer.copy()
new_packer.pack(PickStrategy.SHUFFLE)
new_ratio = new_packer.get_fill_ratio()
if new_ratio > best_ratio:
best_ratio = new_ratio
best_packer = new_packer
return best_packer
def pack_item_subset(
packer: AbstractPacker, picker: Iterable, strategy=PickStrategy.BIGGER_FIRST
) -> None:
"""Pack a subset of `packer.items`, which are chosen by an iterable
yielding a True or False value for each item.
"""
assert packer.is_packed is False
chosen, rejects = get_item_subset(packer.items, picker)
packer.items = chosen
packer.pack(strategy) # unfitted items remain in packer.items
packer.items.extend(rejects) # append rejects as unfitted items
def get_item_subset(
items: list[Item], picker: Iterable
) -> tuple[list[Item], list[Item]]:
"""Returns a subset of `items`, where items are chosen by an iterable
yielding a True or False value for each item.
"""
chosen: list[Item] = []
rejects: list[Item] = []
count = 0
for item, pick in zip(items, picker):
count += 1
if pick:
chosen.append(item)
else:
rejects.append(item)
if count < len(items): # too few pick values given
rejects.extend(items[count:])
return chosen, rejects
class SubSetEvaluator(ga.Evaluator):
def __init__(self, packer: AbstractPacker):
self.packer = packer
def evaluate(self, dna: ga.DNA) -> float:
packer = self.run_packer(dna)
return packer.get_fill_ratio()
def run_packer(self, dna: ga.DNA) -> AbstractPacker:
packer = self.packer.copy()
pack_item_subset(packer, dna)
return packer
class Packer(AbstractPacker):
"""3D Packer inherited from :class:`AbstractPacker`."""
def add_bin(
self,
name: str,
width: float,
height: float,
depth: float,
max_weight: float = UNLIMITED_WEIGHT,
) -> Box:
"""Add a 3D :class:`Box` container."""
box = Box(name, width, height, depth, max_weight)
self.append_bin(box)
return box
def add_item(
self,
payload,
width: float,
height: float,
depth: float,
weight: float = 0.0,
) -> Item:
"""Add a 3D :class:`Item` to pack."""
item = Item(payload, width, height, depth, weight)
self.append_item(item)
return item
class FlatPacker(AbstractPacker):
"""2D Packer inherited from :class:`AbstractPacker`. All containers and
items used by this packer must have a depth of 1."""
def add_bin(
self,
name: str,
width: float,
height: float,
max_weight: float = UNLIMITED_WEIGHT,
) -> Envelope:
"""Add a 2D :class:`Envelope` container."""
envelope = Envelope(name, width, height, max_weight)
self.append_bin(envelope)
return envelope
def add_item(
self,
payload,
width: float,
height: float,
weight: float = 0.0,
) -> Item:
"""Add a 2D :class:`FlatItem` to pack."""
item = FlatItem(payload, width, height, weight)
self.append_item(item)
return item
@staticmethod
def _axis() -> Iterable[Axis]:
return Axis.WIDTH, Axis.HEIGHT
def export_dxf(
layout: "GenericLayoutType", bins: list[Bin], offset: UVec = (1, 0, 0)
) -> None:
from ezdxf import colors
offset_vec = Vec3(offset)
start = Vec3()
index = 0
rgb = (colors.RED, colors.GREEN, colors.BLUE, colors.MAGENTA, colors.CYAN)
for box in bins:
m = Matrix44.translate(start.x, start.y, start.z)
_add_frame(layout, box, "FRAME", m)
for item in box.items:
_add_mesh(layout, item, "ITEMS", rgb[index], m)
index += 1
if index >= len(rgb):
index = 0
start += offset_vec
def _add_frame(layout: "GenericLayoutType", box: Bin, layer: str, m: Matrix44):
def add_line(v1, v2):
line = layout.add_line(v1, v2, dxfattribs=attribs)
line.transform(m)
attribs = {"layer": layer}
x0, y0, z0 = (0.0, 0.0, 0.0)
x1 = float(box.width)
y1 = float(box.height)
z1 = float(box.depth)
corners = [
(x0, y0),
(x1, y0),
(x1, y1),
(x0, y1),
(x0, y0),
]
for (sx, sy), (ex, ey) in zip(corners, corners[1:]):
add_line((sx, sy, z0), (ex, ey, z0))
add_line((sx, sy, z1), (ex, ey, z1))
for x, y in corners[:-1]:
add_line((x, y, z0), (x, y, z1))
text = layout.add_text(box.name, height=0.25, dxfattribs=attribs)
text.set_placement((x0 + 0.25, y1 - 0.5, z1))
text.transform(m)
def _add_mesh(
layout: "GenericLayoutType", item: Item, layer: str, color: int, m: Matrix44
):
from ezdxf.render.forms import cube
attribs = {
"layer": layer,
"color": color,
}
mesh = cube(center=False)
sx, sy, sz = item.get_dimension()
mesh.scale(sx, sy, sz)
x, y, z = item.position
mesh.translate(x, y, z)
mesh.render_polyface(layout, attribs, matrix=m)
text = layout.add_text(
str(item.payload), height=0.25, dxfattribs={"layer": "TEXT"}
)
if sy > sx:
text.dxf.rotation = 90
align = TextEntityAlignment.TOP_LEFT
else:
align = TextEntityAlignment.BOTTOM_LEFT
text.set_placement((x + 0.25, y + 0.25, z + sz), align=align)
text.transform(m)
@@ -0,0 +1,6 @@
# Copyright (c) 2021, Manfred Moitzi
# License: MIT License
from .data import *
from .model import *
from .browser import *
@@ -0,0 +1,33 @@
# Copyright (c) 2021, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import NamedTuple, Optional
class Bookmark(NamedTuple):
name: str
handle: str
offset: int
class Bookmarks:
def __init__(self) -> None:
self.bookmarks: dict[str, Bookmark] = dict()
def add(self, name: str, handle: str, offset: int):
self.bookmarks[name] = Bookmark(name, handle, offset)
def get(self, name: str) -> Optional[Bookmark]:
return self.bookmarks.get(name)
def names(self) -> list[str]:
return list(self.bookmarks.keys())
def discard(self, name: str):
try:
del self.bookmarks[name]
except KeyError:
pass
def clear(self):
self.bookmarks.clear()
@@ -0,0 +1,796 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Optional, Set
from functools import partial
from pathlib import Path
import subprocess
import shlex
from ezdxf.addons.xqt import (
QtWidgets,
QtGui,
QAction,
QMessageBox,
QFileDialog,
QInputDialog,
Qt,
QModelIndex,
QSettings,
QFileSystemWatcher,
QSize,
)
import ezdxf
from ezdxf.lldxf.const import DXFStructureError, DXFValueError
from ezdxf.lldxf.types import DXFTag, is_pointer_code
from ezdxf.lldxf.tags import Tags
from ezdxf.addons.browser.reflinks import get_reference_link
from .model import (
DXFStructureModel,
DXFTagsModel,
DXFTagsRole,
)
from .data import (
DXFDocument,
get_row_from_line_number,
dxfstr,
EntityHistory,
SearchIndex,
)
from .views import StructureTree, DXFTagsTable
from .find_dialog import Ui_FindDialog
from .bookmarks import Bookmarks
__all__ = ["DXFStructureBrowser"]
APP_NAME = "DXF Structure Browser"
BROWSE_COMMAND = ezdxf.options.BROWSE_COMMAND
TEXT_EDITOR = ezdxf.options.get(BROWSE_COMMAND, "TEXT_EDITOR")
ICON_SIZE = max(16, ezdxf.options.get_int(BROWSE_COMMAND, "ICON_SIZE"))
SearchSections = Set[str]
def searchable_entities(
doc: DXFDocument, search_sections: SearchSections
) -> list[Tags]:
entities: list[Tags] = []
for name, section_entities in doc.sections.items():
if name in search_sections:
entities.extend(section_entities) # type: ignore
return entities
BROWSER_WIDTH = 1024
BROWSER_HEIGHT = 768
TREE_WIDTH_FACTOR = 0.33
class DXFStructureBrowser(QtWidgets.QMainWindow):
def __init__(
self,
filename: str = "",
line: Optional[int] = None,
handle: Optional[str] = None,
resource_path: Path = Path("."),
):
super().__init__()
self.doc = DXFDocument()
self.resource_path = resource_path
self._structure_tree = StructureTree()
self._dxf_tags_table = DXFTagsTable()
self._current_entity: Optional[Tags] = None
self._active_search: Optional[SearchIndex] = None
self._search_sections: set[str] = set()
self._find_dialog: FindDialog = self.create_find_dialog()
self._file_watcher = QFileSystemWatcher()
self._exclusive_reload_dialog = True # see ask_for_reloading() method
self.history = EntityHistory()
self.bookmarks = Bookmarks()
self.setup_actions()
self.setup_menu()
self.setup_toolbar()
if filename:
self.load_dxf(filename)
else:
self.setWindowTitle(APP_NAME)
self.setCentralWidget(self.build_central_widget())
self.resize(BROWSER_WIDTH, BROWSER_HEIGHT)
self.connect_slots()
if line is not None:
try:
line = int(line)
except ValueError:
print(f"Invalid line number: {line}")
else:
self.goto_line(line)
if handle is not None:
try:
int(handle, 16)
except ValueError:
print(f"Given handle is not a hex value: {handle}")
else:
if not self.goto_handle(handle):
print(f"Handle {handle} not found.")
def build_central_widget(self):
container = QtWidgets.QSplitter(Qt.Horizontal)
container.addWidget(self._structure_tree)
container.addWidget(self._dxf_tags_table)
tree_width = int(BROWSER_WIDTH * TREE_WIDTH_FACTOR)
table_width = BROWSER_WIDTH - tree_width
container.setSizes([tree_width, table_width])
container.setCollapsible(0, False)
container.setCollapsible(1, False)
return container
def connect_slots(self):
self._structure_tree.activated.connect(self.entity_activated)
self._dxf_tags_table.activated.connect(self.tag_activated)
# noinspection PyUnresolvedReferences
self._file_watcher.fileChanged.connect(self.ask_for_reloading)
# noinspection PyAttributeOutsideInit
def setup_actions(self):
self._open_action = self.make_action(
"&Open DXF File...", self.open_dxf, shortcut="Ctrl+O"
)
self._export_entity_action = self.make_action(
"&Export DXF Entity...", self.export_entity, shortcut="Ctrl+E"
)
self._copy_entity_action = self.make_action(
"&Copy DXF Entity to Clipboard",
self.copy_entity,
shortcut="Shift+Ctrl+C",
icon_name="icon-copy-64px.png",
)
self._copy_selected_tags_action = self.make_action(
"&Copy selected DXF Tags to Clipboard",
self.copy_selected_tags,
shortcut="Ctrl+C",
icon_name="icon-copy-64px.png",
)
self._quit_action = self.make_action(
"&Quit", self.close, shortcut="Ctrl+Q"
)
self._goto_handle_action = self.make_action(
"&Go to Handle...",
self.ask_for_handle,
shortcut="Ctrl+G",
icon_name="icon-goto-handle-64px.png",
tip="Go to Entity Handle",
)
self._goto_line_action = self.make_action(
"Go to &Line...",
self.ask_for_line_number,
shortcut="Ctrl+L",
icon_name="icon-goto-line-64px.png",
tip="Go to Line Number",
)
self._find_text_action = self.make_action(
"Find &Text...",
self.find_text,
shortcut="Ctrl+F",
icon_name="icon-find-64px.png",
tip="Find Text in Entities",
)
self._goto_predecessor_entity_action = self.make_action(
"&Previous Entity",
self.goto_previous_entity,
shortcut="Ctrl+Left",
icon_name="icon-prev-entity-64px.png",
tip="Go to Previous Entity in File Order",
)
self._goto_next_entity_action = self.make_action(
"&Next Entity",
self.goto_next_entity,
shortcut="Ctrl+Right",
icon_name="icon-next-entity-64px.png",
tip="Go to Next Entity in File Order",
)
self._entity_history_back_action = self.make_action(
"Entity History &Back",
self.go_back_entity_history,
shortcut="Alt+Left",
icon_name="icon-left-arrow-64px.png",
tip="Go to Previous Entity in Browser History",
)
self._entity_history_forward_action = self.make_action(
"Entity History &Forward",
self.go_forward_entity_history,
shortcut="Alt+Right",
icon_name="icon-right-arrow-64px.png",
tip="Go to Next Entity in Browser History",
)
self._open_entity_in_text_editor_action = self.make_action(
"&Open in Text Editor",
self.open_entity_in_text_editor,
shortcut="Ctrl+T",
)
self._show_entity_in_tree_view_action = self.make_action(
"Show Entity in Structure &Tree",
self.show_current_entity_in_tree_view,
shortcut="Ctrl+Down",
icon_name="icon-show-in-tree-64px.png",
tip="Show Current Entity in Structure Tree",
)
self._goto_header_action = self.make_action(
"Go to HEADER Section",
partial(self.go_to_section, name="HEADER"),
shortcut="Shift+H",
)
self._goto_blocks_action = self.make_action(
"Go to BLOCKS Section",
partial(self.go_to_section, name="BLOCKS"),
shortcut="Shift+B",
)
self._goto_entities_action = self.make_action(
"Go to ENTITIES Section",
partial(self.go_to_section, name="ENTITIES"),
shortcut="Shift+E",
)
self._goto_objects_action = self.make_action(
"Go to OBJECTS Section",
partial(self.go_to_section, name="OBJECTS"),
shortcut="Shift+O",
)
self._store_bookmark = self.make_action(
"Store Bookmark...",
self.store_bookmark,
shortcut="Shift+Ctrl+B",
icon_name="icon-store-bookmark-64px.png",
)
self._go_to_bookmark = self.make_action(
"Go to Bookmark...",
self.go_to_bookmark,
shortcut="Ctrl+B",
icon_name="icon-goto-bookmark-64px.png",
)
self._reload_action = self.make_action(
"Reload DXF File",
self.reload_dxf,
shortcut="Ctrl+R",
)
def make_action(
self,
name,
slot,
*,
shortcut: str = "",
icon_name: str = "",
tip: str = "",
) -> QAction:
action = QAction(name, self)
if shortcut:
action.setShortcut(shortcut)
if icon_name:
icon = QtGui.QIcon(str(self.resource_path / icon_name))
action.setIcon(icon)
if tip:
action.setToolTip(tip)
action.triggered.connect(slot)
return action
def setup_menu(self):
menu = self.menuBar()
file_menu = menu.addMenu("&File")
file_menu.addAction(self._open_action)
file_menu.addAction(self._reload_action)
file_menu.addAction(self._open_entity_in_text_editor_action)
file_menu.addSeparator()
file_menu.addAction(self._copy_selected_tags_action)
file_menu.addAction(self._copy_entity_action)
file_menu.addAction(self._export_entity_action)
file_menu.addSeparator()
file_menu.addAction(self._quit_action)
navigate_menu = menu.addMenu("&Navigate")
navigate_menu.addAction(self._goto_handle_action)
navigate_menu.addAction(self._goto_line_action)
navigate_menu.addAction(self._find_text_action)
navigate_menu.addSeparator()
navigate_menu.addAction(self._goto_next_entity_action)
navigate_menu.addAction(self._goto_predecessor_entity_action)
navigate_menu.addAction(self._show_entity_in_tree_view_action)
navigate_menu.addSeparator()
navigate_menu.addAction(self._entity_history_back_action)
navigate_menu.addAction(self._entity_history_forward_action)
navigate_menu.addSeparator()
navigate_menu.addAction(self._goto_header_action)
navigate_menu.addAction(self._goto_blocks_action)
navigate_menu.addAction(self._goto_entities_action)
navigate_menu.addAction(self._goto_objects_action)
bookmarks_menu = menu.addMenu("&Bookmarks")
bookmarks_menu.addAction(self._store_bookmark)
bookmarks_menu.addAction(self._go_to_bookmark)
def setup_toolbar(self) -> None:
toolbar = QtWidgets.QToolBar("MainToolbar")
toolbar.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
toolbar.addAction(self._entity_history_back_action)
toolbar.addAction(self._entity_history_forward_action)
toolbar.addAction(self._goto_predecessor_entity_action)
toolbar.addAction(self._goto_next_entity_action)
toolbar.addAction(self._show_entity_in_tree_view_action)
toolbar.addAction(self._find_text_action)
toolbar.addAction(self._goto_line_action)
toolbar.addAction(self._goto_handle_action)
toolbar.addAction(self._store_bookmark)
toolbar.addAction(self._go_to_bookmark)
toolbar.addAction(self._copy_selected_tags_action)
self.addToolBar(toolbar)
def create_find_dialog(self) -> "FindDialog":
dialog = FindDialog()
dialog.setModal(True)
dialog.find_forward_button.clicked.connect(self.find_forward)
dialog.find_backwards_button.clicked.connect(self.find_backwards)
dialog.find_forward_button.setShortcut("F3")
dialog.find_backwards_button.setShortcut("F4")
return dialog
def open_dxf(self):
path, _ = QtWidgets.QFileDialog.getOpenFileName(
self,
caption="Select DXF file",
filter="DXF Documents (*.dxf *.DXF)",
)
if path:
self.load_dxf(path)
def load_dxf(self, path: str):
try:
self._load(path)
except IOError as e:
QMessageBox.critical(self, "Loading Error", str(e))
except DXFStructureError as e:
QMessageBox.critical(
self,
"DXF Structure Error",
f'Invalid DXF file "{path}": {str(e)}',
)
else:
self.history.clear()
self.view_header_section()
self.update_title()
def reload_dxf(self):
if self._current_entity is not None:
entity = self.get_current_entity()
handle = self.get_current_entity_handle()
first_row = self._dxf_tags_table.first_selected_row()
line_number = self.doc.get_line_number(entity, first_row)
self._load(self.doc.filename)
if handle is not None:
entity = self.doc.get_entity(handle)
if entity is not None: # select entity with same handle
self.set_current_entity_and_row_index(entity, first_row)
self._structure_tree.expand_to_entity(entity)
return
# select entity at the same line number
entity = self.doc.get_entity_at_line(line_number)
self.set_current_entity_and_row_index(entity, first_row)
self._structure_tree.expand_to_entity(entity)
def ask_for_reloading(self):
if self.doc.filename and self._exclusive_reload_dialog:
# Ignore further reload signals until first signal is processed.
# Saving files by ezdxf triggers two "fileChanged" signals!?
self._exclusive_reload_dialog = False
ok = QMessageBox.question(
self,
"Reload",
f'"{self.doc.absolute_filepath()}"\n\nThis file has been '
f"modified by another program, reload file?",
buttons=QMessageBox.Yes | QMessageBox.No,
defaultButton=QMessageBox.Yes,
)
if ok == QMessageBox.Yes:
self.reload_dxf()
self._exclusive_reload_dialog = True
def _load(self, filename: str):
if self.doc.filename:
self._file_watcher.removePath(self.doc.filename)
self.doc.load(filename)
model = DXFStructureModel(self.doc.filepath.name, self.doc)
self._structure_tree.set_structure(model)
self.history.clear()
self._file_watcher.addPath(self.doc.filename)
def export_entity(self):
if self._dxf_tags_table is None:
return
path, _ = QFileDialog.getSaveFileName(
self,
caption="Export DXF Entity",
filter="Text Files (*.txt *.TXT)",
)
if path:
model = self._dxf_tags_table.model()
tags = model.compiled_tags()
self.export_tags(path, tags)
def copy_entity(self):
if self._dxf_tags_table is None:
return
model = self._dxf_tags_table.model()
tags = model.compiled_tags()
copy_dxf_to_clipboard(tags)
def copy_selected_tags(self):
if self._current_entity is None:
return
rows = self._dxf_tags_table.selected_rows()
model = self._dxf_tags_table.model()
tags = model.compiled_tags()
try:
export_tags = Tags(tags[row] for row in rows)
except IndexError:
return
copy_dxf_to_clipboard(export_tags)
def view_header_section(self):
header = self.doc.get_section("HEADER")
if header:
self.set_current_entity_with_history(header[0])
else: # DXF R12 with only a ENTITIES section
entities = self.doc.get_section("ENTITIES")
if entities:
self.set_current_entity_with_history(entities[1])
def update_title(self):
self.setWindowTitle(f"{APP_NAME} - {self.doc.absolute_filepath()}")
def get_current_entity_handle(self) -> Optional[str]:
active_entity = self.get_current_entity()
if active_entity:
try:
return active_entity.get_handle()
except DXFValueError:
pass
return None
def get_current_entity(self) -> Optional[Tags]:
return self._current_entity
def set_current_entity_by_handle(self, handle: str):
entity = self.doc.get_entity(handle)
if entity:
self.set_current_entity(entity)
def set_current_entity(
self, entity: Tags, select_line_number: Optional[int] = None
):
if entity:
self._current_entity = entity
start_line_number = self.doc.get_line_number(entity)
model = DXFTagsModel(
entity, start_line_number, self.doc.entity_index
)
self._dxf_tags_table.setModel(model)
if select_line_number is not None:
row = get_row_from_line_number(
model.compiled_tags(), start_line_number, select_line_number
)
self._dxf_tags_table.selectRow(row)
index = self._dxf_tags_table.model().index(row, 0)
self._dxf_tags_table.scrollTo(index)
def set_current_entity_with_history(self, entity: Tags):
self.set_current_entity(entity)
self.history.append(entity)
def set_current_entity_and_row_index(self, entity: Tags, index: int):
line = self.doc.get_line_number(entity, index)
self.set_current_entity(entity, select_line_number=line)
self.history.append(entity)
def entity_activated(self, index: QModelIndex):
tags = index.data(role=DXFTagsRole)
# PySide6: Tags() are converted to type list by PySide6?
# print(type(tags))
if isinstance(tags, (Tags, list)):
self.set_current_entity_with_history(Tags(tags))
def tag_activated(self, index: QModelIndex):
tag = index.data(role=DXFTagsRole)
if isinstance(tag, DXFTag):
code, value = tag
if is_pointer_code(code):
if not self.goto_handle(value):
self.show_error_handle_not_found(value)
elif code == 0:
self.open_web_browser(get_reference_link(value))
def ask_for_handle(self):
handle, ok = QInputDialog.getText(
self,
"Go to",
"Go to entity handle:",
)
if ok:
if not self.goto_handle(handle):
self.show_error_handle_not_found(handle)
def goto_handle(self, handle: str) -> bool:
entity = self.doc.get_entity(handle)
if entity:
self.set_current_entity_with_history(entity)
return True
return False
def show_error_handle_not_found(self, handle: str):
QMessageBox.critical(self, "Error", f"Handle {handle} not found!")
def ask_for_line_number(self):
max_line_number = self.doc.max_line_number
number, ok = QInputDialog.getInt(
self,
"Go to",
f"Go to line number: (max. {max_line_number})",
1, # value
1, # PyQt5: min, PySide6: minValue
max_line_number, # PyQt5: max, PySide6: maxValue
)
if ok:
self.goto_line(number)
def goto_line(self, number: int) -> bool:
entity = self.doc.get_entity_at_line(int(number))
if entity:
self.set_current_entity(entity, number)
return True
return False
def find_text(self):
self._active_search = None
dialog = self._find_dialog
dialog.restore_geometry()
dialog.show_message("F3 searches forward, F4 searches backwards")
dialog.find_text_edit.setFocus()
dialog.show()
def update_search(self):
def setup_search():
self._search_sections = dialog.search_sections()
entities = searchable_entities(self.doc, self._search_sections)
self._active_search = SearchIndex(entities)
dialog = self._find_dialog
if self._active_search is None:
setup_search()
# noinspection PyUnresolvedReferences
self._active_search.set_current_entity(self._current_entity)
else:
search_sections = dialog.search_sections()
if search_sections != self._search_sections:
setup_search()
dialog.update_options(self._active_search)
def find_forward(self):
self._find(backward=False)
def find_backwards(self):
self._find(backward=True)
def _find(self, backward=False):
if self._find_dialog.isVisible():
self.update_search()
search = self._active_search
if search.is_end_of_index:
search.reset_cursor(backward=backward)
entity, index = (
search.find_backwards() if backward else search.find_forward()
)
if entity:
self.set_current_entity_and_row_index(entity, index)
self.show_entity_found_message(entity, index)
else:
if search.is_end_of_index:
self.show_message("Not found and end of file!")
else:
self.show_message("Not found!")
def show_message(self, msg: str):
self._find_dialog.show_message(msg)
def show_entity_found_message(self, entity: Tags, index: int):
dxftype = entity.dxftype()
if dxftype == "SECTION":
tail = " @ {0} Section".format(entity.get_first_value(2))
else:
try:
handle = entity.get_handle()
tail = f" @ {dxftype}(#{handle})"
except ValueError:
tail = ""
line = self.doc.get_line_number(entity, index)
self.show_message(f"Found in Line: {line}{tail}")
def export_tags(self, filename: str, tags: Tags):
try:
with open(filename, "wt", encoding="utf8") as fp:
fp.write(dxfstr(tags))
except IOError as e:
QMessageBox.critical(self, "IOError", str(e))
def goto_next_entity(self):
if self._dxf_tags_table:
current_entity = self.get_current_entity()
if current_entity is not None:
next_entity = self.doc.next_entity(current_entity)
if next_entity is not None:
self.set_current_entity_with_history(next_entity)
def goto_previous_entity(self):
if self._dxf_tags_table:
current_entity = self.get_current_entity()
if current_entity is not None:
prev_entity = self.doc.previous_entity(current_entity)
if prev_entity is not None:
self.set_current_entity_with_history(prev_entity)
def go_back_entity_history(self):
entity = self.history.back()
if entity is not None:
self.set_current_entity(entity) # do not change history
def go_forward_entity_history(self):
entity = self.history.forward()
if entity is not None:
self.set_current_entity(entity) # do not change history
def go_to_section(self, name: str):
section = self.doc.get_section(name)
if section:
index = 0 if name == "HEADER" else 1
self.set_current_entity_with_history(section[index])
def open_entity_in_text_editor(self):
current_entity = self.get_current_entity()
line_number = self.doc.get_line_number(current_entity)
if self._dxf_tags_table:
indices = self._dxf_tags_table.selectedIndexes()
if indices:
model = self._dxf_tags_table.model()
row = indices[0].row()
line_number = model.line_number(row)
self._open_text_editor(
str(self.doc.absolute_filepath()), line_number
)
def _open_text_editor(self, filename: str, line_number: int) -> None:
cmd = TEXT_EDITOR.format(
filename=filename,
num=line_number,
)
args = shlex.split(cmd)
try:
subprocess.Popen(args)
except FileNotFoundError:
QMessageBox.critical(
self, "Text Editor", "Error calling text editor:\n" + cmd
)
def open_web_browser(self, url: str):
import webbrowser
webbrowser.open(url)
def show_current_entity_in_tree_view(self):
entity = self.get_current_entity()
if entity:
self._structure_tree.expand_to_entity(entity)
def store_bookmark(self):
if self._current_entity is not None:
bookmarks = self.bookmarks.names()
if len(bookmarks) == 0:
bookmarks = ["0"]
name, ok = QInputDialog.getItem(
self,
"Store Bookmark",
"Bookmark:",
bookmarks,
editable=True,
)
if ok:
entity = self._current_entity
rows = self._dxf_tags_table.selectedIndexes()
if rows:
offset = rows[0].row()
else:
offset = 0
handle = self.doc.get_handle(entity)
self.bookmarks.add(name, handle, offset)
def go_to_bookmark(self):
bookmarks = self.bookmarks.names()
if len(bookmarks) == 0:
QMessageBox.information(self, "Info", "No Bookmarks defined!")
return
name, ok = QInputDialog.getItem(
self,
"Go to Bookmark",
"Bookmark:",
self.bookmarks.names(),
editable=False,
)
if ok:
bookmark = self.bookmarks.get(name)
if bookmark is not None:
self.set_current_entity_by_handle(bookmark.handle)
self._dxf_tags_table.selectRow(bookmark.offset)
model = self._dxf_tags_table.model()
index = QModelIndex(model.index(bookmark.offset, 0))
self._dxf_tags_table.scrollTo(index)
else:
QtWidgets.QMessageBox.critical(
self, "Bookmark not found!", str(name)
)
def copy_dxf_to_clipboard(tags: Tags):
clipboard = QtWidgets.QApplication.clipboard()
try:
mode = clipboard.Mode.Clipboard
except AttributeError:
mode = clipboard.Clipboard # type: ignore # legacy location
clipboard.setText(dxfstr(tags), mode=mode)
class FindDialog(QtWidgets.QDialog, Ui_FindDialog):
def __init__(self):
super().__init__()
self.setupUi(self)
self.close_button.clicked.connect(lambda: self.close())
self.settings = QSettings("ezdxf", "DXFBrowser")
def restore_geometry(self):
geometry = self.settings.value("find.dialog.geometry")
if geometry is not None:
self.restoreGeometry(geometry)
def search_sections(self) -> SearchSections:
sections = set()
if self.header_check_box.isChecked():
sections.add("HEADER")
if self.classes_check_box.isChecked():
sections.add("CLASSES")
if self.tables_check_box.isChecked():
sections.add("TABLES")
if self.blocks_check_box.isChecked():
sections.add("BLOCKS")
if self.entities_check_box.isChecked():
sections.add("ENTITIES")
if self.objects_check_box.isChecked():
sections.add("OBJECTS")
return sections
def update_options(self, search: SearchIndex) -> None:
search.reset_search_term(self.find_text_edit.text())
search.case_insensitive = not self.match_case_check_box.isChecked()
search.whole_words = self.whole_words_check_box.isChecked()
search.numbers = self.number_tags_check_box.isChecked()
def closeEvent(self, event):
self.settings.setValue("find.dialog.geometry", self.saveGeometry())
super().closeEvent(event)
def show_message(self, msg: str):
self.message.setText(msg)
@@ -0,0 +1,428 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Optional, Iterable, Any, TYPE_CHECKING
from pathlib import Path
from ezdxf.addons.browser.loader import load_section_dict
from ezdxf.lldxf.types import DXFVertex, tag_type
from ezdxf.lldxf.tags import Tags
if TYPE_CHECKING:
from ezdxf.eztypes import SectionDict
__all__ = [
"DXFDocument",
"IndexEntry",
"get_row_from_line_number",
"dxfstr",
"EntityHistory",
"SearchIndex",
]
class DXFDocument:
def __init__(self, sections: Optional[SectionDict] = None):
# Important: the section dict has to store the raw string tags
# else an association of line numbers to entities is not possible.
# Comment tags (999) are ignored, because the load_section_dict()
# function can not handle and store comments.
# Therefore comments causes incorrect results for the line number
# associations and should be stripped off before processing for precise
# debugging of DXF files (-b for backup):
# ezdxf strip -b <your.dxf>
self.sections: SectionDict = dict()
self.entity_index: Optional[EntityIndex] = None
self.valid_handles = None
self.filename = ""
if sections:
self.update(sections)
@property
def filepath(self):
return Path(self.filename)
@property
def max_line_number(self) -> int:
if self.entity_index:
return self.entity_index.max_line_number
else:
return 1
def load(self, filename: str):
self.filename = filename
self.update(load_section_dict(filename))
def update(self, sections: SectionDict):
self.sections = sections
self.entity_index = EntityIndex(self.sections)
def absolute_filepath(self):
return self.filepath.absolute()
def get_section(self, name: str) -> list[Tags]:
return self.sections.get(name) # type: ignore
def get_entity(self, handle: str) -> Optional[Tags]:
if self.entity_index:
return self.entity_index.get(handle)
return None
def get_line_number(self, entity: Tags, offset: int = 0) -> int:
if self.entity_index:
return (
self.entity_index.get_start_line_for_entity(entity) + offset * 2
)
return 0
def get_entity_at_line(self, number: int) -> Optional[Tags]:
if self.entity_index:
return self.entity_index.get_entity_at_line(number)
return None
def next_entity(self, entity: Tags) -> Optional[Tags]:
return self.entity_index.next_entity(entity) # type: ignore
def previous_entity(self, entity: Tags) -> Optional[Tags]:
return self.entity_index.previous_entity(entity) # type: ignore
def get_handle(self, entity) -> Optional[str]:
return self.entity_index.get_handle(entity) # type: ignore
class IndexEntry:
def __init__(self, tags: Tags, line: int = 0):
self.tags: Tags = tags
self.start_line_number: int = line
self.prev: Optional["IndexEntry"] = None
self.next: Optional["IndexEntry"] = None
class EntityIndex:
def __init__(self, sections: SectionDict):
# dict() entries have to be ordered since Python 3.6!
# Therefore _index.values() returns the DXF entities in file order!
self._index: dict[str, IndexEntry] = dict()
# Index dummy handle of entities without handles by the id of the
# first tag for faster retrieval of the dummy handle from tags:
# dict items: (id, handle)
self._dummy_handle_index: dict[int, str] = dict()
self._max_line_number: int = 0
self._build(sections)
def _build(self, sections: SectionDict) -> None:
start_line_number = 1
dummy_handle = 1
entity_index: dict[str, IndexEntry] = dict()
dummy_handle_index: dict[int, str] = dict()
prev_entry: Optional[IndexEntry] = None
for section in sections.values():
for tags in section:
assert isinstance(tags, Tags), "expected class Tags"
assert len(tags) > 0, "empty tags should not be possible"
try:
handle = tags.get_handle().upper()
except ValueError:
handle = f"*{dummy_handle:X}"
# index dummy handle by id of the first tag:
dummy_handle_index[id(tags[0])] = handle
dummy_handle += 1
next_entry = IndexEntry(tags, start_line_number)
if prev_entry is not None:
next_entry.prev = prev_entry
prev_entry.next = next_entry
entity_index[handle] = next_entry
prev_entry = next_entry
# calculate next start line number:
# add 2 lines for each tag: group code, value
start_line_number += len(tags) * 2
start_line_number += 2 # for removed ENDSEC tag
# subtract 1 and 2 for the last ENDSEC tag!
self._max_line_number = start_line_number - 3
self._index = entity_index
self._dummy_handle_index = dummy_handle_index
def __contains__(self, handle: str) -> bool:
return handle.upper() in self._index
@property
def max_line_number(self) -> int:
return self._max_line_number
def get(self, handle: str) -> Optional[Tags]:
index_entry = self._index.get(handle.upper())
if index_entry is not None:
return index_entry.tags
else:
return None
def get_handle(self, entity: Tags) -> Optional[str]:
if not len(entity):
return None
try:
return entity.get_handle()
except ValueError:
# fast retrieval of dummy handle which isn't stored in tags:
return self._dummy_handle_index.get(id(entity[0]))
def next_entity(self, entity: Tags) -> Tags:
handle = self.get_handle(entity)
if handle:
index_entry = self._index.get(handle)
next_entry = index_entry.next # type: ignore
# next of last entity is None!
if next_entry:
return next_entry.tags
return entity
def previous_entity(self, entity: Tags) -> Tags:
handle = self.get_handle(entity)
if handle:
index_entry = self._index.get(handle)
prev_entry = index_entry.prev # type: ignore
# prev of first entity is None!
if prev_entry:
return prev_entry.tags
return entity
def get_start_line_for_entity(self, entity: Tags) -> int:
handle = self.get_handle(entity)
if handle:
index_entry = self._index.get(handle)
if index_entry:
return index_entry.start_line_number
return 0
def get_entity_at_line(self, number: int) -> Optional[Tags]:
tags = None
for index_entry in self._index.values():
if index_entry.start_line_number > number:
return tags # tags of previous entry!
tags = index_entry.tags
return tags
def get_row_from_line_number(
entity: Tags, start_line_number: int, select_line_number: int
) -> int:
count = select_line_number - start_line_number
lines = 0
row = 0
for tag in entity:
if lines >= count:
return row
if isinstance(tag, DXFVertex):
lines += len(tag.value) * 2
else:
lines += 2
row += 1
return row
def dxfstr(tags: Tags) -> str:
return "".join(tag.dxfstr() for tag in tags)
class EntityHistory:
def __init__(self) -> None:
self._history: list[Tags] = list()
self._index: int = 0
self._time_travel: list[Tags] = list()
def __len__(self):
return len(self._history)
@property
def index(self):
return self._index
def clear(self):
self._history.clear()
self._time_travel.clear()
self._index = 0
def append(self, entity: Tags):
if self._time_travel:
self._history.extend(self._time_travel)
self._time_travel.clear()
count = len(self._history)
if count:
# only append if different to last entity
if self._history[-1] is entity:
return
self._index = count
self._history.append(entity)
def back(self) -> Optional[Tags]:
entity = None
if self._history:
index = self._index - 1
if index >= 0:
entity = self._time_wrap(index)
else:
entity = self._history[0]
return entity
def forward(self) -> Tags:
entity = None
history = self._history
if history:
index = self._index + 1
if index < len(history):
entity = self._time_wrap(index)
else:
entity = history[-1]
return entity # type: ignore
def _time_wrap(self, index) -> Tags:
self._index = index
entity = self._history[index]
self._time_travel.append(entity)
return entity
def content(self) -> list[Tags]:
return list(self._history)
class SearchIndex:
NOT_FOUND = None, -1
def __init__(self, entities: Iterable[Tags]):
self.entities: list[Tags] = list(entities)
self._current_entity_index: int = 0
self._current_tag_index: int = 0
self._search_term: Optional[str] = None
self._search_term_lower: Optional[str] = None
self._backward = False
self._end_of_index = not bool(self.entities)
self.case_insensitive = True
self.whole_words = False
self.numbers = False
self.regex = False # False = normal mode
@property
def is_end_of_index(self) -> bool:
return self._end_of_index
@property
def search_term(self) -> Optional[str]:
return self._search_term
def set_current_entity(self, entity: Tags, tag_index: int = 0):
self._current_tag_index = tag_index
try:
self._current_entity_index = self.entities.index(entity)
except ValueError:
self.reset_cursor()
def update_entities(self, entities: list[Tags]):
current_entity, index = self.current_entity()
self.entities = entities
if current_entity:
self.set_current_entity(current_entity, index)
def current_entity(self) -> tuple[Optional[Tags], int]:
if self.entities and not self._end_of_index:
return (
self.entities[self._current_entity_index],
self._current_tag_index,
)
return self.NOT_FOUND
def reset_cursor(self, backward: bool = False):
self._current_entity_index = 0
self._current_tag_index = 0
count = len(self.entities)
if count:
self._end_of_index = False
if backward:
self._current_entity_index = count - 1
entity = self.entities[-1]
self._current_tag_index = len(entity) - 1
else:
self._end_of_index = True
def cursor(self) -> tuple[int, int]:
return self._current_entity_index, self._current_tag_index
def move_cursor_forward(self) -> None:
if self.entities:
entity: Tags = self.entities[self._current_entity_index]
tag_index = self._current_tag_index + 1
if tag_index >= len(entity):
entity_index = self._current_entity_index + 1
if entity_index < len(self.entities):
self._current_entity_index = entity_index
self._current_tag_index = 0
else:
self._end_of_index = True
else:
self._current_tag_index = tag_index
def move_cursor_backward(self) -> None:
if self.entities:
tag_index = self._current_tag_index - 1
if tag_index < 0:
entity_index = self._current_entity_index - 1
if entity_index >= 0:
self._current_entity_index = entity_index
self._current_tag_index = (
len(self.entities[entity_index]) - 1
)
else:
self._end_of_index = True
else:
self._current_tag_index = tag_index
def reset_search_term(self, term: str) -> None:
self._search_term = str(term)
self._search_term_lower = self._search_term.lower()
def find(
self, term: str, backward: bool = False, reset_index: bool = True
) -> tuple[Optional[Tags], int]:
self.reset_search_term(term)
if reset_index:
self.reset_cursor(backward)
if len(self.entities) and not self._end_of_index:
if backward:
return self.find_backwards()
else:
return self.find_forward()
else:
return self.NOT_FOUND
def find_forward(self) -> tuple[Optional[Tags], int]:
return self._find(self.move_cursor_forward)
def find_backwards(self) -> tuple[Optional[Tags], int]:
return self._find(self.move_cursor_backward)
def _find(self, move_cursor) -> tuple[Optional[Tags], int]:
if self.entities and self._search_term and not self._end_of_index:
while not self._end_of_index:
entity, tag_index = self.current_entity()
move_cursor()
if self._match(*entity[tag_index]): # type: ignore
return entity, tag_index
return self.NOT_FOUND
def _match(self, code: int, value: Any) -> bool:
if tag_type(code) is not str:
if not self.numbers:
return False
value = str(value)
if self.case_insensitive:
search_term = self._search_term_lower
value = value.lower()
else:
search_term = self._search_term
if self.whole_words:
return any(search_term == word for word in value.split())
else:
return search_term in value
@@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'find_dialog.ui'
#
# Created by: PyQt5 UI code generator 5.15.2
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# run again. Do not edit this file unless you know what you are doing.
from ezdxf.addons.xqt import QtCore, QtGui, QtWidgets
class Ui_FindDialog(object):
def setupUi(self, FindDialog):
FindDialog.setObjectName("FindDialog")
FindDialog.resize(320, 376)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(FindDialog.sizePolicy().hasHeightForWidth())
FindDialog.setSizePolicy(sizePolicy)
FindDialog.setMinimumSize(QtCore.QSize(320, 376))
FindDialog.setMaximumSize(QtCore.QSize(320, 376))
FindDialog.setBaseSize(QtCore.QSize(320, 376))
self.verticalLayout_5 = QtWidgets.QVBoxLayout(FindDialog)
self.verticalLayout_5.setObjectName("verticalLayout_5")
self.verticalLayout = QtWidgets.QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
self.horizontalLayout.setObjectName("horizontalLayout")
self.label = QtWidgets.QLabel(FindDialog)
self.label.setObjectName("label")
self.horizontalLayout.addWidget(self.label)
self.find_text_edit = QtWidgets.QLineEdit(FindDialog)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.find_text_edit.sizePolicy().hasHeightForWidth())
self.find_text_edit.setSizePolicy(sizePolicy)
self.find_text_edit.setMinimumSize(QtCore.QSize(0, 24))
self.find_text_edit.setMaximumSize(QtCore.QSize(16777215, 24))
self.find_text_edit.setObjectName("find_text_edit")
self.horizontalLayout.addWidget(self.find_text_edit)
self.verticalLayout.addLayout(self.horizontalLayout)
self.groupBox = QtWidgets.QGroupBox(FindDialog)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.groupBox.sizePolicy().hasHeightForWidth())
self.groupBox.setSizePolicy(sizePolicy)
self.groupBox.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
self.groupBox.setFlat(False)
self.groupBox.setObjectName("groupBox")
self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.groupBox)
self.verticalLayout_3.setObjectName("verticalLayout_3")
self.whole_words_check_box = QtWidgets.QCheckBox(self.groupBox)
self.whole_words_check_box.setObjectName("whole_words_check_box")
self.verticalLayout_3.addWidget(self.whole_words_check_box)
self.match_case_check_box = QtWidgets.QCheckBox(self.groupBox)
self.match_case_check_box.setObjectName("match_case_check_box")
self.verticalLayout_3.addWidget(self.match_case_check_box)
self.number_tags_check_box = QtWidgets.QCheckBox(self.groupBox)
self.number_tags_check_box.setObjectName("number_tags_check_box")
self.verticalLayout_3.addWidget(self.number_tags_check_box)
self.verticalLayout.addWidget(self.groupBox, 0, QtCore.Qt.AlignTop)
self.groupBox_2 = QtWidgets.QGroupBox(FindDialog)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
self.groupBox_2.setSizePolicy(sizePolicy)
self.groupBox_2.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
self.groupBox_2.setObjectName("groupBox_2")
self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.groupBox_2)
self.verticalLayout_4.setObjectName("verticalLayout_4")
self.header_check_box = QtWidgets.QCheckBox(self.groupBox_2)
self.header_check_box.setChecked(True)
self.header_check_box.setObjectName("header_check_box")
self.verticalLayout_4.addWidget(self.header_check_box)
self.classes_check_box = QtWidgets.QCheckBox(self.groupBox_2)
self.classes_check_box.setObjectName("classes_check_box")
self.verticalLayout_4.addWidget(self.classes_check_box)
self.tables_check_box = QtWidgets.QCheckBox(self.groupBox_2)
self.tables_check_box.setChecked(True)
self.tables_check_box.setObjectName("tables_check_box")
self.verticalLayout_4.addWidget(self.tables_check_box)
self.blocks_check_box = QtWidgets.QCheckBox(self.groupBox_2)
self.blocks_check_box.setChecked(True)
self.blocks_check_box.setObjectName("blocks_check_box")
self.verticalLayout_4.addWidget(self.blocks_check_box)
self.entities_check_box = QtWidgets.QCheckBox(self.groupBox_2)
self.entities_check_box.setChecked(True)
self.entities_check_box.setObjectName("entities_check_box")
self.verticalLayout_4.addWidget(self.entities_check_box)
self.objects_check_box = QtWidgets.QCheckBox(self.groupBox_2)
self.objects_check_box.setChecked(False)
self.objects_check_box.setObjectName("objects_check_box")
self.verticalLayout_4.addWidget(self.objects_check_box)
self.verticalLayout.addWidget(self.groupBox_2, 0, QtCore.Qt.AlignTop)
self.verticalLayout_5.addLayout(self.verticalLayout)
self.message = QtWidgets.QLabel(FindDialog)
self.message.setObjectName("message")
self.verticalLayout_5.addWidget(self.message)
self.buttons_layout = QtWidgets.QHBoxLayout()
self.buttons_layout.setSizeConstraint(QtWidgets.QLayout.SetNoConstraint)
self.buttons_layout.setObjectName("buttons_layout")
self.find_forward_button = QtWidgets.QPushButton(FindDialog)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.find_forward_button.sizePolicy().hasHeightForWidth())
self.find_forward_button.setSizePolicy(sizePolicy)
self.find_forward_button.setMinimumSize(QtCore.QSize(0, 0))
self.find_forward_button.setMaximumSize(QtCore.QSize(200, 100))
self.find_forward_button.setObjectName("find_forward_button")
self.buttons_layout.addWidget(self.find_forward_button, 0, QtCore.Qt.AlignBottom)
self.find_backwards_button = QtWidgets.QPushButton(FindDialog)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.find_backwards_button.sizePolicy().hasHeightForWidth())
self.find_backwards_button.setSizePolicy(sizePolicy)
self.find_backwards_button.setMinimumSize(QtCore.QSize(0, 0))
self.find_backwards_button.setMaximumSize(QtCore.QSize(200, 100))
self.find_backwards_button.setObjectName("find_backwards_button")
self.buttons_layout.addWidget(self.find_backwards_button, 0, QtCore.Qt.AlignBottom)
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
self.buttons_layout.addItem(spacerItem)
self.close_button = QtWidgets.QPushButton(FindDialog)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.close_button.sizePolicy().hasHeightForWidth())
self.close_button.setSizePolicy(sizePolicy)
self.close_button.setMinimumSize(QtCore.QSize(0, 0))
self.close_button.setMaximumSize(QtCore.QSize(200, 100))
self.close_button.setToolTip("")
self.close_button.setObjectName("close_button")
self.buttons_layout.addWidget(self.close_button, 0, QtCore.Qt.AlignRight|QtCore.Qt.AlignBottom)
self.verticalLayout_5.addLayout(self.buttons_layout)
self.retranslateUi(FindDialog)
QtCore.QMetaObject.connectSlotsByName(FindDialog)
def retranslateUi(self, FindDialog):
_translate = QtCore.QCoreApplication.translate
FindDialog.setWindowTitle(_translate("FindDialog", "Find"))
self.label.setText(_translate("FindDialog", "Find Text:"))
self.groupBox.setTitle(_translate("FindDialog", "Options"))
self.whole_words_check_box.setToolTip(_translate("FindDialog", "Search only whole words in normal mode if checked."))
self.whole_words_check_box.setText(_translate("FindDialog", "Whole Words"))
self.match_case_check_box.setToolTip(_translate("FindDialog", "Case sensitive search in normal mode if checked."))
self.match_case_check_box.setText(_translate("FindDialog", "Match Case"))
self.number_tags_check_box.setToolTip(_translate("FindDialog", "Ignore numeric DXF tags if checked."))
self.number_tags_check_box.setText(_translate("FindDialog", "Search in Numeric Tags"))
self.groupBox_2.setToolTip(_translate("FindDialog", "Select sections to search in."))
self.groupBox_2.setTitle(_translate("FindDialog", "Search in Sections"))
self.header_check_box.setText(_translate("FindDialog", "HEADER"))
self.classes_check_box.setText(_translate("FindDialog", "CLASSES"))
self.tables_check_box.setText(_translate("FindDialog", "TABLES"))
self.blocks_check_box.setText(_translate("FindDialog", "BLOCKS"))
self.entities_check_box.setText(_translate("FindDialog", "ENTITIES"))
self.objects_check_box.setText(_translate("FindDialog", "OBJECTS"))
self.message.setText(_translate("FindDialog", "TextLabel"))
self.find_forward_button.setToolTip(_translate("FindDialog", "or press F3"))
self.find_forward_button.setText(_translate("FindDialog", "Find &Forward"))
self.find_backwards_button.setToolTip(_translate("FindDialog", "or press F4"))
self.find_backwards_button.setText(_translate("FindDialog", "Find &Backwards"))
self.close_button.setText(_translate("FindDialog", "Close"))
@@ -0,0 +1,36 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Union, Iterable, TYPE_CHECKING
from pathlib import Path
from ezdxf.lldxf import loader
from ezdxf.lldxf.types import DXFTag
from ezdxf.lldxf.tagger import ascii_tags_loader, binary_tags_loader
from ezdxf.lldxf.validator import is_dxf_file, is_binary_dxf_file
from ezdxf.filemanagement import dxf_file_info
if TYPE_CHECKING:
from ezdxf.eztypes import SectionDict
def load_section_dict(filename: Union[str, Path]) -> SectionDict:
tagger = get_tag_loader(filename)
return loader.load_dxf_structure(tagger)
def get_tag_loader(
filename: Union[str, Path], errors: str = "ignore"
) -> Iterable[DXFTag]:
filename = str(filename)
if is_binary_dxf_file(filename):
with open(filename, "rb") as fp:
data = fp.read()
return binary_tags_loader(data, errors=errors)
if not is_dxf_file(filename):
raise IOError(f"File '{filename}' is not a DXF file.")
info = dxf_file_info(filename)
with open(filename, mode="rt", encoding=info.encoding, errors=errors) as fp:
return list(ascii_tags_loader(fp, skip_comments=True))
@@ -0,0 +1,574 @@
# Copyright (c) 2021, Manfred Moitzi
# License: MIT License
# mypy: ignore_errors=True
from __future__ import annotations
from typing import Any, Optional
import textwrap
from ezdxf.lldxf.types import (
render_tag,
DXFVertex,
GROUP_MARKERS,
POINTER_CODES,
)
from ezdxf.addons.xqt import QModelIndex, QAbstractTableModel, Qt, QtWidgets
from ezdxf.addons.xqt import QStandardItemModel, QStandardItem, QColor
from .tags import compile_tags, Tags
__all__ = [
"DXFTagsModel",
"DXFStructureModel",
"EntityContainer",
"Entity",
"DXFTagsRole",
]
DXFTagsRole = Qt.UserRole + 1 # type: ignore
def name_fmt(handle, name: str) -> str:
if handle is None:
return name
else:
return f"<{handle}> {name}"
HEADER_LABELS = ["Group Code", "Data Type", "Content", "4", "5"]
def calc_line_numbers(start: int, tags: Tags) -> list[int]:
numbers = [start]
index = start
for tag in tags:
if isinstance(tag, DXFVertex):
index += len(tag.value) * 2
else:
index += 2
numbers.append(index)
return numbers
class DXFTagsModel(QAbstractTableModel):
def __init__(
self, tags: Tags, start_line_number: int = 1, valid_handles=None
):
super().__init__()
self._tags = compile_tags(tags)
self._line_numbers = calc_line_numbers(start_line_number, self._tags)
self._valid_handles = valid_handles or set()
palette = QtWidgets.QApplication.palette()
self._group_marker_color = palette.highlight().color()
def data(self, index: QModelIndex, role: int = ...) -> Any: # type: ignore
def is_invalid_handle(tag):
if (
tag.code in POINTER_CODES
and not tag.value.upper() in self._valid_handles
):
return True
return False
if role == Qt.DisplayRole:
tag = self._tags[index.row()]
return render_tag(tag, index.column())
elif role == Qt.ForegroundRole:
tag = self._tags[index.row()]
if tag.code in GROUP_MARKERS:
return self._group_marker_color
elif is_invalid_handle(tag):
return QColor("red")
elif role == DXFTagsRole:
return self._tags[index.row()]
elif role == Qt.ToolTipRole:
code, value = self._tags[index.row()]
if index.column() == 0: # group code column
return GROUP_CODE_TOOLTIPS_DICT.get(code)
code, value = self._tags[index.row()]
if code in POINTER_CODES:
if value.upper() in self._valid_handles:
return f"Double click to go to the referenced entity"
else:
return f"Handle does not exist"
elif code == 0:
return f"Double click to go to the DXF reference provided by Autodesk"
def headerData(
self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore
) -> Any:
if orientation == Qt.Horizontal:
if role == Qt.DisplayRole:
return HEADER_LABELS[section]
elif role == Qt.TextAlignmentRole:
return Qt.AlignLeft
elif orientation == Qt.Vertical:
if role == Qt.DisplayRole:
return self._line_numbers[section]
elif role == Qt.ToolTipRole:
return "Line number in DXF file"
def rowCount(self, parent: QModelIndex = ...) -> int: # type: ignore
return len(self._tags)
def columnCount(self, parent: QModelIndex = ...) -> int: # type: ignore
return 3
def compiled_tags(self) -> Tags:
"""Returns the compiled tags. Only points codes are compiled, group
code 10, ...
"""
return self._tags
def line_number(self, row: int) -> int:
"""Return the DXF file line number of the widget-row."""
try:
return self._line_numbers[row]
except IndexError:
return 0
class EntityContainer(QStandardItem):
def __init__(self, name: str, entities: list[Tags]):
super().__init__()
self.setEditable(False)
self.setText(name + f" ({len(entities)})")
self.setup_content(entities)
def setup_content(self, entities):
self.appendRows([Entity(e) for e in entities])
class Classes(EntityContainer):
def setup_content(self, entities):
self.appendRows([Class(e) for e in entities])
class AcDsData(EntityContainer):
def setup_content(self, entities):
self.appendRows([AcDsEntry(e) for e in entities])
class NamedEntityContainer(EntityContainer):
def setup_content(self, entities):
self.appendRows([NamedEntity(e) for e in entities])
class Tables(EntityContainer):
def setup_content(self, entities):
container = []
name = ""
for e in entities:
container.append(e)
dxftype = e.dxftype()
if dxftype == "TABLE":
try:
handle = e.get_handle()
except ValueError:
handle = None
name = e.get_first_value(2, default="UNDEFINED")
name = name_fmt(handle, name)
elif dxftype == "ENDTAB":
if container:
container.pop() # remove ENDTAB
self.appendRow(NamedEntityContainer(name, container))
container.clear()
class Blocks(EntityContainer):
def setup_content(self, entities):
container = []
name = "UNDEFINED"
for e in entities:
container.append(e)
dxftype = e.dxftype()
if dxftype == "BLOCK":
try:
handle = e.get_handle()
except ValueError:
handle = None
name = e.get_first_value(2, default="UNDEFINED")
name = name_fmt(handle, name)
elif dxftype == "ENDBLK":
if container:
self.appendRow(EntityContainer(name, container))
container.clear()
def get_section_name(section: list[Tags]) -> str:
if len(section) > 0:
header = section[0]
if len(header) > 1 and header[0].code == 0 and header[1].code == 2:
return header[1].value
return "INVALID SECTION HEADER!"
class Entity(QStandardItem):
def __init__(self, tags: Tags):
super().__init__()
self.setEditable(False)
self._tags = tags
self._handle: Optional[str]
try:
self._handle = tags.get_handle()
except ValueError:
self._handle = None
self.setText(self.entity_name())
def entity_name(self):
name = "INVALID ENTITY!"
tags = self._tags
if tags and tags[0].code == 0:
name = name_fmt(self._handle, tags[0].value)
return name
def data(self, role: int = ...) -> Any: # type: ignore
if role == DXFTagsRole:
return self._tags
else:
return super().data(role)
class Header(Entity):
def entity_name(self):
return "HEADER"
class ThumbnailImage(Entity):
def entity_name(self):
return "THUMBNAILIMAGE"
class NamedEntity(Entity):
def entity_name(self):
name = self._tags.get_first_value(2, "<noname>")
return name_fmt(str(self._handle), name)
class Class(Entity):
def entity_name(self):
tags = self._tags
name = "INVALID CLASS!"
if len(tags) > 1 and tags[0].code == 0 and tags[1].code == 1:
name = tags[1].value
return name
class AcDsEntry(Entity):
def entity_name(self):
return self._tags[0].value
class DXFStructureModel(QStandardItemModel):
def __init__(self, filename: str, doc):
super().__init__()
root = QStandardItem(filename)
root.setEditable(False)
self.appendRow(root)
row: Any
for section in doc.sections.values():
name = get_section_name(section)
if name == "HEADER":
row = Header(section[0])
elif name == "THUMBNAILIMAGE":
row = ThumbnailImage(section[0])
elif name == "CLASSES":
row = Classes(name, section[1:])
elif name == "TABLES":
row = Tables(name, section[1:])
elif name == "BLOCKS":
row = Blocks(name, section[1:])
elif name == "ACDSDATA":
row = AcDsData(name, section[1:])
else:
row = EntityContainer(name, section[1:])
root.appendRow(row)
def index_of_entity(self, entity: Tags) -> QModelIndex:
root = self.item(0, 0)
index = find_index(root, entity)
if index is None:
return root.index()
else:
return index
def find_index(item: QStandardItem, entity: Tags) -> Optional[QModelIndex]:
def _find(sub_item: QStandardItem):
for index in range(sub_item.rowCount()):
child = sub_item.child(index, 0)
tags = child.data(DXFTagsRole)
if tags and tags is entity:
return child.index()
if child.rowCount() > 0:
index2 = _find(child)
if index2 is not None:
return index2
return None
return _find(item)
GROUP_CODE_TOOLTIPS = [
(0, "Text string indicating the entity type (fixed)"),
(1, "Primary text value for an entity"),
(2, "Name (attribute tag, block name, and so on)"),
((3, 4), "Other text or name values"),
(5, "Entity handle; text string of up to 16 hexadecimal digits (fixed)"),
(6, "Linetype name (fixed)"),
(7, "Text style name (fixed)"),
(8, "Layer name (fixed)"),
(
9,
"DXF: variable name identifier (used only in HEADER section of the DXF file)",
),
(
10,
"Primary point; this is the start point of a line or text entity, center "
"of a circle, and so on DXF: X value of the primary point (followed by Y "
"and Z value codes 20 and 30) APP: 3D point (list of three reals)",
),
(
(11, 18),
"Other points DXF: X value of other points (followed by Y value codes "
"21-28 and Z value codes 31-38) APP: 3D point (list of three reals)",
),
(20, "DXF: Y value of the primary point"),
(30, "DXF: Z value of the primary point"),
((21, 28), "DXF: Y values of other points"),
((31, 37), "DXF: Z values of other points"),
(38, "DXF: entity's elevation if nonzero"),
(39, "Entity's thickness if nonzero (fixed)"),
(
(40, 47),
"Double-precision floating-point values (text height, scale factors, and so on)",
),
(48, "Linetype scale; default value is defined for all entity types"),
(
49,
"Multiple 49 groups may appear in one entity for variable-length tables "
"(such as the dash lengths in the LTYPE table). A 7x group always appears "
"before the first 49 group to specify the table length",
),
(
(50, 58),
"Angles (output in degrees to DXF files and radians through AutoLISP and ObjectARX applications)",
),
(
60,
"Entity visibility; absence or 0 indicates visibility; 1 indicates invisibility",
),
(62, "Color number (fixed)"),
(66, "Entities follow flag (fixed)"),
(67, "0 for model space or 1 for paper space (fixed)"),
(
68,
"APP: identifies whether viewport is on but fully off screen; is not active or is off",
),
(69, "APP: viewport identification number"),
((70, 79), "Integer values, such as repeat counts, flag bits, or modes"),
((90, 99), "32-bit integer values"),
(
100,
"Subclass data marker (with derived class name as a string). "
"Required for all objects and entity classes that are derived from "
"another concrete class. The subclass data marker segregates data defined by different "
"classes in the inheritance chain for the same object. This is in addition "
"to the requirement for DXF names for each distinct concrete class derived "
"from ObjectARX (see Subclass Markers)",
),
(101, "Embedded object marker"),
(
102,
"Control string, followed by '{arbitrary name' or '}'. Similar to the "
"xdata 1002 group code, except that when the string begins with '{', it "
"can be followed by an arbitrary string whose interpretation is up to the "
"application. The only other control string allowed is '}' as a group "
"terminator. AutoCAD does not interpret these strings except during d"
"rawing audit operations. They are for application use.",
),
(105, "Object handle for DIMVAR symbol table entry"),
(
110,
"UCS origin (appears only if code 72 is set to 1); DXF: X value; APP: 3D point",
),
(
111,
"UCS Y-axis (appears only if code 72 is set to 1); DXF: Y value; APP: 3D vector",
),
(
112,
"UCS Z-axis (appears only if code 72 is set to 1); DXF: Z value; APP: 3D vector",
),
((120, 122), "DXF: Y value of UCS origin, UCS X-axis, and UCS Y-axis"),
((130, 132), "DXF: Z value of UCS origin, UCS X-axis, and UCS Y-axis"),
(
(140, 149),
"Double-precision floating-point values (points, elevation, and DIMSTYLE settings, for example)",
),
(
(170, 179),
"16-bit integer values, such as flag bits representing DIMSTYLE settings",
),
(
210,
"Extrusion direction (fixed) "
+ "DXF: X value of extrusion direction "
+ "APP: 3D extrusion direction vector",
),
(220, "DXF: Y value of the extrusion direction"),
(230, "DXF: Z value of the extrusion direction"),
((270, 279), "16-bit integer values"),
((280, 289), "16-bit integer value"),
((290, 299), "Boolean flag value; 0 = False; 1 = True"),
((300, 309), "Arbitrary text strings"),
(
(310, 319),
"Arbitrary binary chunks with same representation and limits as 1004 "
"group codes: hexadecimal strings of up to 254 characters represent data "
"chunks of up to 127 bytes",
),
(
(320, 329),
"Arbitrary object handles; handle values that are taken 'as is'. They "
"are not translated during INSERT and XREF operations",
),
(
(330, 339),
"Soft-pointer handle; arbitrary soft pointers to other objects within "
"same DXF file or drawing. Translated during INSERT and XREF operations",
),
(
(340, 349),
"Hard-pointer handle; arbitrary hard pointers to other objects within "
"same DXF file or drawing. Translated during INSERT and XREF operations",
),
(
(350, 359),
"Soft-owner handle; arbitrary soft ownership links to other objects "
"within same DXF file or drawing. Translated during INSERT and XREF "
"operations",
),
(
(360, 369),
"Hard-owner handle; arbitrary hard ownership links to other objects within "
"same DXF file or drawing. Translated during INSERT and XREF operations",
),
(
(370, 379),
"Lineweight enum value (AcDb::LineWeight). Stored and moved around as a 16-bit integer. "
"Custom non-entity objects may use the full range, but entity classes only use 371-379 DXF "
"group codes in their representation, because AutoCAD and AutoLISP both always assume a 370 "
"group code is the entity's lineweight. This allows 370 to behave like other 'common' entity fields",
),
(
(380, 389),
"PlotStyleName type enum (AcDb::PlotStyleNameType). Stored and moved around as a 16-bit integer. "
"Custom non-entity objects may use the full range, but entity classes only use 381-389 "
"DXF group codes in their representation, for the same reason as the lineweight range",
),
(
(390, 399),
"String representing handle value of the PlotStyleName object, basically a hard pointer, but has "
"a different range to make backward compatibility easier to deal with. Stored and moved around "
"as an object ID (a handle in DXF files) and a special type in AutoLISP. Custom non-entity objects "
"may use the full range, but entity classes only use 391-399 DXF group codes in their representation, "
"for the same reason as the lineweight range",
),
((400, 409), "16-bit integers"),
((410, 419), "String"),
(
(420, 427),
"32-bit integer value. When used with True Color; a 32-bit integer representing a 24-bit color value. "
"The high-order byte (8 bits) is 0, the low-order byte an unsigned char holding the Blue value (0-255), "
"then the Green value, and the next-to-high order byte is the Red Value. Converting this integer value to "
"hexadecimal yields the following bit mask: 0x00RRGGBB. "
"For example, a true color with Red==200, Green==100 and Blue==50 is 0x00C86432, and in DXF, in decimal, 13132850",
),
(
(430, 437),
"String; when used for True Color, a string representing the name of the color",
),
(
(440, 447),
"32-bit integer value. When used for True Color, the transparency value",
),
((450, 459), "Long"),
((460, 469), "Double-precision floating-point value"),
((470, 479), "String"),
(
(480, 481),
"Hard-pointer handle; arbitrary hard pointers to other objects within same DXF file or drawing. "
"Translated during INSERT and XREF operations",
),
(
999,
"DXF: The 999 group code indicates that the line following it is a comment string. SAVEAS does "
"not include such groups in a DXF output file, but OPEN honors them and ignores the comments. "
"You can use the 999 group to include comments in a DXF file that you have edited",
),
(1000, "ASCII string (up to 255 bytes long) in extended data"),
(
1001,
"Registered application name (ASCII string up to 31 bytes long) for extended data",
),
(1002, "Extended data control string ('{' or '}')"),
(1003, "Extended data layer name"),
(1004, "Chunk of bytes (up to 127 bytes long) in extended data"),
(
1005,
"Entity handle in extended data; text string of up to 16 hexadecimal digits",
),
(
1010,
"A point in extended data; DXF: X value (followed by 1020 and 1030 groups); APP: 3D point",
),
(1020, "DXF: Y values of a point"),
(1030, "DXF: Z values of a point"),
(
1011,
"A 3D world space position in extended data "
"DXF: X value (followed by 1021 and 1031 groups) "
"APP: 3D point",
),
(1021, "DXF: Y value of a world space position"),
(1031, "DXF: Z value of a world space position"),
(
1012,
"A 3D world space displacement in extended data "
"DXF: X value (followed by 1022 and 1032 groups) "
"APP: 3D vector",
),
(1022, "DXF: Y value of a world space displacement"),
(1032, "DXF: Z value of a world space displacement"),
(
1013,
"A 3D world space direction in extended data "
"DXF: X value (followed by 1022 and 1032 groups) "
"APP: 3D vector",
),
(1023, "DXF: Y value of a world space direction"),
(1033, "DXF: Z value of a world space direction"),
(1040, "Extended data double-precision floating-point value"),
(1041, "Extended data distance value"),
(1042, "Extended data scale factor"),
(1070, "Extended data 16-bit signed integer"),
(1071, "Extended data 32-bit signed long"),
]
def build_group_code_tooltip_dict() -> dict[int, str]:
tooltips = dict()
for code, tooltip in GROUP_CODE_TOOLTIPS:
tooltip = "\n".join(textwrap.wrap(tooltip, width=80))
if isinstance(code, int):
tooltips[code] = tooltip
elif isinstance(code, tuple):
s, e = code
for group_code in range(s, e + 1):
tooltips[group_code] = tooltip
else:
raise ValueError(type(code))
return tooltips
GROUP_CODE_TOOLTIPS_DICT = build_group_code_tooltip_dict()
@@ -0,0 +1,117 @@
# Autodesk DXF 2018 Reference
link_tpl = "https://help.autodesk.com/view/OARX/2018/ENU/?guid={guid}"
# Autodesk DXF 2014 Reference
# link_tpl = 'https://docs.autodesk.com/ACD/2014/ENU/files/{guid}.htm'
main_index_guid = "GUID-235B22E0-A567-4CF6-92D3-38A2306D73F3" # main index
reference_guids = {
"HEADER": "GUID-EA9CDD11-19D1-4EBC-9F56-979ACF679E3C",
"CLASSES": "GUID-6160F1F1-2805-4C69-8077-CA1AEB6B1005",
"TABLES": "GUID-A9FD9590-C97B-4E41-9F26-BD82C34A4F9F",
"BLOCKS": "GUID-1D14A213-5E4D-4EA6-A6B5-8709EB925D01",
"ENTITIES": "GUID-7D07C886-FD1D-4A0C-A7AB-B4D21F18E484",
"OBJECTS": "GUID-2D71EE99-A6BE-4060-9B43-808CF1E201C6",
"THUMBNAILIMAGE": "GUID-792F79DC-0D5D-43B5-AB0E-212E0EDF6BAE",
"APPIDS": "GUID-6E3140E9-E560-4C77-904E-480382F0553E",
"APPID": "GUID-6E3140E9-E560-4C77-904E-480382F0553E",
"BLOCK_RECORDS": "GUID-A1FD1934-7EF5-4D35-A4B0-F8AE54A9A20A",
"BLOCK_RECORD": "GUID-A1FD1934-7EF5-4D35-A4B0-F8AE54A9A20A",
"DIMSTYLES": "GUID-F2FAD36F-0CE3-4943-9DAD-A9BCD2AE81DA",
"DIMSTYLE": "GUID-F2FAD36F-0CE3-4943-9DAD-A9BCD2AE81DA",
"LAYERS": "GUID-D94802B0-8BE8-4AC9-8054-17197688AFDB",
"LAYER": "GUID-D94802B0-8BE8-4AC9-8054-17197688AFDB",
"LINETYPES": "GUID-F57A316C-94A2-416C-8280-191E34B182AC",
"LTYPE": "GUID-F57A316C-94A2-416C-8280-191E34B182AC",
"STYLES": "GUID-EF68AF7C-13EF-45A1-8175-ED6CE66C8FC9",
"STYLE": "GUID-EF68AF7C-13EF-45A1-8175-ED6CE66C8FC9",
"UCS": "GUID-1906E8A7-3393-4BF9-BD27-F9AE4352FB8B",
"VIEWS": "GUID-CF3094AB-ECA9-43C1-8075-7791AC84F97C",
"VIEW": "GUID-CF3094AB-ECA9-43C1-8075-7791AC84F97C",
"VIEWPORTS": "GUID-8CE7CC87-27BD-4490-89DA-C21F516415A9",
"VPORT": "GUID-8CE7CC87-27BD-4490-89DA-C21F516415A9",
"BLOCK": "GUID-66D32572-005A-4E23-8B8B-8726E8C14302",
"ENDBLK": "GUID-27F7CC8A-E340-4C7F-A77F-5AF139AD502D",
"3DFACE": "GUID-747865D5-51F0-45F2-BEFE-9572DBC5B151",
"3DSOLID": "GUID-19AB1C40-0BE0-4F32-BCAB-04B37044A0D3",
"ACAD_PROXY_ENTITY": "GUID-89A690F9-E859-4D57-89EA-750F3FB76C6B",
"ARC": "GUID-0B14D8F1-0EBA-44BF-9108-57D8CE614BC8",
"ATTDEF": "GUID-F0EA099B-6F88-4BCC-BEC7-247BA64838A4",
"ATTRIB": "GUID-7DD8B495-C3F8-48CD-A766-14F9D7D0DD9B",
"BODY": "GUID-7FB91514-56FF-4487-850E-CF1047999E77",
"CIRCLE": "GUID-8663262B-222C-414D-B133-4A8506A27C18",
"DIMENSION": "GUID-239A1BDD-7459-4BB9-8DD7-08EC79BF1EB0",
"ELLIPSE": "GUID-107CB04F-AD4D-4D2F-8EC9-AC90888063AB",
"HATCH": "GUID-C6C71CED-CE0F-4184-82A5-07AD6241F15B",
"HELIX": "GUID-76DB3ABF-3C8C-47D1-8AFB-72942D9AE1FF",
"IMAGE": "GUID-3A2FF847-BE14-4AC5-9BD4-BD3DCAEF2281",
"INSERT": "GUID-28FA4CFB-9D5E-4880-9F11-36C97578252F",
"LEADER": "GUID-396B2369-F89F-47D7-8223-8B7FB794F9F3",
"LIGHT": "GUID-1A23DB42-6A92-48E9-9EB2-A7856A479930",
"LINE": "GUID-FCEF5726-53AE-4C43-B4EA-C84EB8686A66",
"LWPOLYLINE": "GUID-748FC305-F3F2-4F74-825A-61F04D757A50",
"MESH": "GUID-4B9ADA67-87C8-4673-A579-6E4C76FF7025",
"MLINE": "GUID-590E8AE3-C6D9-4641-8485-D7B3693E432C",
"MLEADERSTYLE": "GUID-0E489B69-17A4-4439-8505-9DCE032100B4",
"MLEADER": "GUID-72D20B8C-0F5E-4993-BEB7-0FCF94F32BE0",
"MULTILEADER": "GUID-72D20B8C-0F5E-4993-BEB7-0FCF94F32BE0",
"MTEXT": "GUID-5E5DB93B-F8D3-4433-ADF7-E92E250D2BAB",
"OLEFRAME": "GUID-4A10EF68-35A3-4961-8B15-1222ECE5E8C6",
"OLE2FRAME": "GUID-77747CE6-82C6-4452-97ED-4CEEB38BE960",
"POINT": "GUID-9C6AD32D-769D-4213-85A4-CA9CCB5C5317",
"POLYLINE": "GUID-ABF6B778-BE20-4B49-9B58-A94E64CEFFF3",
"RAY": "GUID-638B9F01-5D86-408E-A2DE-FA5D6ADBD415",
"REGION": "GUID-644BF0F0-FD79-4C5E-AD5A-0053FCC5A5A4",
"SECTION": "GUID-8B60CBAB-B226-4A5F-ABB1-46FD8AABB928",
"SEQEND": "GUID-FD4FAA74-1F6D-45F6-B132-BF0C4BE6CC3B",
"SHAPE": "GUID-0988D755-9AAB-4D6C-8E26-EC636F507F2C",
"SOLID": "GUID-E0C5F04E-D0C5-48F5-AC09-32733E8848F2",
"SPLINE": "GUID-E1F884F8-AA90-4864-A215-3182D47A9C74",
"SUN": "GUID-BB191D89-9302-45E4-9904-108AB418FAE1",
"SURFACE": "GUID-BB62483A-89C3-47C4-80E5-EA3F08979863",
"TABLE": "GUID-D8CCD2F0-18A3-42BB-A64D-539114A07DA0",
"TEXT": "GUID-62E5383D-8A14-47B4-BFC4-35824CAE8363",
"TOLERANCE": "GUID-ADFCED35-B312-4996-B4C1-61C53757B3FD",
"TRACE": "GUID-EA6FBCA8-1AD6-4FB2-B149-770313E93511",
"UNDERLAY": "GUID-3EC8FBCC-A85A-4B0B-93CD-C6C785959077",
"VERTEX": "GUID-0741E831-599E-4CBF-91E1-8ADBCFD6556D",
"VIEWPORT": "GUID-2602B0FB-02E4-4B9A-B03C-B1D904753D34",
"WIPEOUT": "GUID-2229F9C4-3C80-4C67-9EDA-45ED684808DC",
"XLINE": "GUID-55080553-34B6-40AA-9EE2-3F3A3A2A5C0A",
"ACAD_PROXY_OBJECT": "GUID-F59F0EC3-D34D-4C1A-91AC-7FDA569EF016",
"ACDBDICTIONARYWDFLT": "GUID-A6605C05-1CF4-42A4-95EC-42190B2424EE",
"ACDBPLACEHOLDER": "GUID-3BC75FF1-6139-49F4-AEBB-AE2AB4F437E4",
"DATATABLE": "GUID-D09D0650-B926-40DD-A2F2-4FD5BDDFC330",
"DICTIONARY": "GUID-40B92C63-26F0-485B-A9C2-B349099B26D0",
"DICTIONARYVAR": "GUID-D305303C-F9CE-4714-9C92-607BFDA891B4",
"DIMASSOC": "GUID-C0B96256-A911-4B4D-85E6-EB4AF2C91E27",
"FIELD": "GUID-51B921F2-16CA-4948-AC75-196198DD1796",
"GEODATA": "GUID-104FE0E2-4801-4AC8-B92C-1DDF5AC7AB64",
"GROUP": "GUID-5F1372C4-37C8-4056-9303-EE1715F58E67",
"IDBUFFER": "GUID-7A243F2B-72D8-4C48-A29A-3F251B86D03F",
"IMAGEDEF": "GUID-EFE5319F-A71A-4612-9431-42B6C7C3941F",
"IMAGEDEF_REACTOR": "GUID-46C12333-1EDA-4619-B2C9-D7D2607110C8",
"LAYER_INDEX": "GUID-17560B05-31B9-44A5-BA92-E92C799398C0",
"LAYER_FILTER": "GUID-3B44DCFD-FA96-482B-8468-37B3C5B5F289",
"LAYOUT": "GUID-433D25BF-655D-4697-834E-C666EDFD956D",
"LIGHTLIST": "GUID-C4E7FFF8-C3ED-43DD-854D-304F87FFCF06",
"MATERIAL": "GUID-E540C5BB-E166-44FA-B36C-5C739878B272",
"MLINESTYLE": "GUID-3EC12E5B-F5F6-484D-880F-D69EBE186D79",
"OBJECT_PTR": "GUID-6D6885E2-281C-410A-92FB-8F6A7F54C9DF",
"PLOTSETTINGS": "GUID-1113675E-AB07-4567-801A-310CDE0D56E9",
"RASTERVARIABLES": "GUID-DDCC21A4-822A-469B-9954-1E1EC4F6DF82",
"SPATIAL_INDEX": "GUID-CD1E44DA-CDBA-4AA7-B08E-C53F71648984",
"SPATIAL_FILTER": "GUID-34F179D8-2030-47E4-8D49-F87B6538A05A",
"SORTENTSTABLE": "GUID-462F4378-F850-4E89-90F2-3C1880F55779",
"SUNSTUDY": "GUID-1C7C073F-4CFD-4939-97D9-7AB0C1E163A3",
"TABLESTYLE": "GUID-0DBCA057-9F6C-4DEB-A66F-8A9B3C62FB1A",
"UNDERLAYDEFINITION": "GUID-A4FF15D3-F745-4E1F-94D4-1DC3DF297B0F",
"VISUALSTYLE": "GUID-8A8BF2C4-FC56-44EC-A8C4-A60CE33A530C",
"VBA_PROJECT": "GUID-F247DB75-5C4D-4944-8C20-1567480221F4",
"WIPEOUTVARIABLES": "GUID-CD28B95F-483C-4080-82A6-420606F88356",
"XRECORD": "GUID-24668FAF-AE03-41AE-AFA4-276C3692827F",
}
def get_reference_link(name: str) -> str:
guid = reference_guids.get(name, main_index_guid)
return link_tpl.format(guid=guid)
@@ -0,0 +1,71 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from typing import Iterable, Sequence
from ezdxf.lldxf.tags import Tags
from ezdxf.lldxf.types import (
DXFTag,
DXFVertex,
POINT_CODES,
TYPE_TABLE,
)
def tag_compiler(tags: Tags) -> Iterable[DXFTag]:
"""Special tag compiler for the DXF browser.
This compiler should never fail and always return printable tags:
- invalid point coordinates are returned as float("nan")
- invalid ints are returned as string
- invalid floats are returned as string
"""
def to_float(v: str) -> float:
try:
return float(v)
except ValueError:
return float("NaN")
count = len(tags)
index = 0
while index < count:
code, value = tags[index]
if code in POINT_CODES:
try:
y_code, y_value = tags[index + 1]
except IndexError: # x-coord as last tag
yield DXFTag(code, to_float(value))
return
if y_code != code + 10: # not an y-coord?
yield DXFTag(code, to_float(value)) # x-coord as single tag
index += 1
continue
try:
z_code, z_value = tags[index + 2]
except IndexError: # no z-coord exist
z_code = 0
z_value = 0
point: Sequence[float]
if z_code == code + 20: # is a z-coord?
point = (to_float(value), to_float(y_value), to_float(z_value))
index += 3
else: # a valid 2d point(x, y)
point = (to_float(value), to_float(y_value))
index += 2
yield DXFVertex(code, point)
else: # a single tag
try:
if code == 0:
value = value.strip()
yield DXFTag(code, TYPE_TABLE.get(code, str)(value))
except ValueError:
yield DXFTag(code, str(value)) # just as string
index += 1
def compile_tags(tags: Tags) -> Tags:
return Tags(tag_compiler(tags))
@@ -0,0 +1,41 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from ezdxf.addons.xqt import QTableView, QTreeView, QModelIndex
from ezdxf.lldxf.tags import Tags
class StructureTree(QTreeView):
def set_structure(self, model):
self.setModel(model)
self.expand(model.index(0, 0, QModelIndex()))
self.setHeaderHidden(True)
def expand_to_entity(self, entity: Tags):
model = self.model()
index = model.index_of_entity(entity) # type: ignore
self.setCurrentIndex(index)
class DXFTagsTable(QTableView):
def __init__(self):
super().__init__()
col_header = self.horizontalHeader()
col_header.setStretchLastSection(True)
row_header = self.verticalHeader()
row_header.setDefaultSectionSize(24) # default row height in pixels
self.setSelectionBehavior(QTableView.SelectRows)
def first_selected_row(self) -> int:
first_row: int = 0
selection = self.selectedIndexes()
if selection:
first_row = selection[0].row()
return first_row
def selected_rows(self) -> list[int]:
rows: set[int] = set()
selection = self.selectedIndexes()
for item in selection:
rows.add(item.row())
return sorted(rows)
@@ -0,0 +1,766 @@
# Copyright (c) 2010-2022, Manfred Moitzi
# License: MIT License
"""Dimension lines as composite entities build up by DXF primitives.
This add-on exist just for an easy transition from `dxfwrite` to `ezdxf`.
Classes
-------
- LinearDimension
- AngularDimension
- ArcDimension
- RadiusDimension
- DimStyle
This code was written long before I had any understanding of the DIMENSION
entity and therefore, the classes have completely different implementations and
styling features than the dimensions based on the DIMENSION entity .
.. warning::
Try to not use these classes beside porting `dxfwrite` code to `ezdxf`
and even for that case the usage of the regular DIMENSION entity is to
prefer because this module will not get much maintenance and may be removed
in the future.
"""
from __future__ import annotations
from typing import Any, TYPE_CHECKING, Iterable, Optional
from math import radians, degrees, pi
from abc import abstractmethod
from ezdxf.enums import TextEntityAlignment
from ezdxf.math import Vec3, Vec2, distance, lerp, ConstructionRay, UVec
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.eztypes import GenericLayoutType
DIMENSIONS_MIN_DISTANCE = 0.05
DIMENSIONS_FLOATINGPOINT = "."
ANGLE_DEG = 180.0 / pi
ANGLE_GRAD = 200.0 / pi
ANGLE_RAD = 1.0
class DimStyle(dict):
"""DimStyle parameter struct, a dumb object just to store values
"""
default_values = [
# tick block name, use setup to generate default blocks <dimblk> <dimblk1> <dimblk2>
("tick", "DIMTICK_ARCH"),
# scale factor for ticks-block <dimtsz> <dimasz>
("tickfactor", 1.0),
# tick2x means tick is drawn only for one side, insert tick a second
# time rotated about 180 degree, but only one time at the dimension line
# ends, this is useful for arrow-like ticks. hint: set dimlineext to 0. <none>
("tick2x", False),
# dimension value scale factor, value = drawing-units * scale <dimlfac>
("scale", 100.0),
# round dimension value to roundval fractional digits <dimdec>
("roundval", 0),
# round dimension value to half units, round 0.4, 0.6 to 0.5 <dimrnd>
("roundhalf", False),
# dimension value text color <dimclrt>
("textcolor", 7),
# dimension value text height <dimtxt>
("height", 0.5),
# dimension text prefix and suffix like 'x=' ... ' cm' <dimpost>
("prefix", ""),
("suffix", ""),
# dimension value text style <dimtxsty>
("style", "OpenSansCondensed-Light"),
# default layer for whole dimension object
("layer", "DIMENSIONS"),
# dimension line color index (0 from layer) <dimclrd>
("dimlinecolor", 7),
# dimension line extensions (in dimline direction, left and right) <dimdle>
("dimlineext", 0.3),
# draw dimension value text `textabove` drawing-units above the
# dimension line <dimgap>
("textabove", 0.2),
# switch extension line False=off, True=on <dimse1> <dimse2>
("dimextline", True),
# dimension extension line color index (0 from layer) <dimclre>
("dimextlinecolor", 5),
# gap between measure target point and end of extension line <dimexo>
("dimextlinegap", 0.3),
]
def __init__(self, name: str, **kwargs):
super().__init__(DimStyle.default_values)
# dimstyle name
self["name"] = name
self.update(kwargs)
def __getattr__(self, attr: str) -> Any:
return self[attr]
def __setattr__(self, attr: str, value: Any) -> None:
self[attr] = value
class DimStyles:
"""
DimStyle container
"""
def __init__(self) -> None:
self._styles: dict[str, DimStyle] = {}
self.default = DimStyle("Default")
self.new(
"angle.deg",
scale=ANGLE_DEG,
suffix=str("°"),
roundval=0,
tick="DIMTICK_RADIUS",
tick2x=True,
dimlineext=0.0,
dimextline=False,
)
self.new(
"angle.grad",
scale=ANGLE_GRAD,
suffix="gon",
roundval=0,
tick="DIMTICK_RADIUS",
tick2x=True,
dimlineext=0.0,
dimextline=False,
)
self.new(
"angle.rad",
scale=ANGLE_RAD,
suffix="rad",
roundval=3,
tick="DIMTICK_RADIUS",
tick2x=True,
dimlineext=0.0,
dimextline=False,
)
def get(self, name: str) -> DimStyle:
"""
Get DimStyle() object by name.
"""
return self._styles.get(name, self.default)
def new(self, name: str, **kwargs) -> DimStyle:
"""
Create a new dimstyle
"""
style = DimStyle(name, **kwargs)
self._styles[name] = style
return style
@staticmethod
def setup(drawing: "Drawing"):
"""
Insert necessary definitions into drawing:
ticks: DIMTICK_ARCH, DIMTICK_DOT, DIMTICK_ARROW
"""
# default pen assignment:
# 1 : 1.40mm - red
# 2 : 0.35mm - yellow
# 3 : 0.70mm - green
# 4 : 0.50mm - cyan
# 5 : 0.13mm - blue
# 6 : 1.00mm - magenta
# 7 : 0.25mm - white/black
# 8, 9 : 2.00mm
# >=10 : 1.40mm
dimcolor = {
"color": dimstyles.default.dimextlinecolor,
"layer": "BYBLOCK",
}
color4 = {"color": 4, "layer": "BYBLOCK"}
color7 = {"color": 7, "layer": "BYBLOCK"}
block = drawing.blocks.new("DIMTICK_ARCH")
block.add_line(start=(0.0, +0.5), end=(0.0, -0.5), dxfattribs=dimcolor)
block.add_line(start=(-0.2, -0.2), end=(0.2, +0.2), dxfattribs=color4)
block = drawing.blocks.new("DIMTICK_DOT")
block.add_line(start=(0.0, 0.5), end=(0.0, -0.5), dxfattribs=dimcolor)
block.add_circle(center=(0, 0), radius=0.1, dxfattribs=color4)
block = drawing.blocks.new("DIMTICK_ARROW")
block.add_line(start=(0.0, 0.5), end=(0.0, -0.50), dxfattribs=dimcolor)
block.add_solid([(0, 0), (0.3, 0.05), (0.3, -0.05)], dxfattribs=color7)
block = drawing.blocks.new("DIMTICK_RADIUS")
block.add_solid(
[(0, 0), (0.3, 0.05), (0.25, 0.0), (0.3, -0.05)], dxfattribs=color7
)
dimstyles = DimStyles() # use this factory to create new dimstyles
class _DimensionBase:
"""
Abstract base class for dimension lines.
"""
def __init__(self, dimstyle: str, layer: str, roundval: int):
self.dimstyle = dimstyles.get(dimstyle)
self.layer = layer
self.roundval = roundval
def prop(self, property_name: str) -> Any:
"""
Get dimension line properties by `property_name` with the possibility to override several properties.
"""
if property_name == "layer":
return self.layer if self.layer is not None else self.dimstyle.layer
elif property_name == "roundval":
return (
self.roundval
if self.roundval is not None
else self.dimstyle.roundval
)
else: # pass through self.dimstyle object DimStyle()
return self.dimstyle[property_name]
def format_dimtext(self, dimvalue: float) -> str:
"""
Format the dimension text.
"""
dimtextfmt = "%." + str(self.prop("roundval")) + "f"
dimtext = dimtextfmt % dimvalue
if DIMENSIONS_FLOATINGPOINT in dimtext:
# remove successional zeros
dimtext.rstrip("0")
# remove floating point as last char
dimtext.rstrip(DIMENSIONS_FLOATINGPOINT)
return self.prop("prefix") + dimtext + self.prop("suffix")
@abstractmethod
def render(self, layout: "GenericLayoutType"):
pass
class LinearDimension(_DimensionBase):
"""
Simple straight dimension line with two or more measure points, build with basic DXF entities. This is NOT a dxf
dimension entity. And This is a 2D element, so all z-values will be ignored!
"""
def __init__(
self,
pos: UVec,
measure_points: Iterable[UVec],
angle: float = 0.0,
dimstyle: str = "Default",
layer: Optional[str] = None,
roundval: Optional[int] = None,
):
"""
LinearDimension Constructor.
Args:
pos: location as (x, y) tuple of dimension line, line goes through this point
measure_points: list of points as (x, y) tuples to dimension (two or more)
angle: angle (in degree) of dimension line
dimstyle: dimstyle name, 'Default' - style is the default value
layer: dimension line layer, override the default value of dimstyle
roundval: count of decimal places
"""
super().__init__(dimstyle, layer, roundval) # type: ignore
self.angle = angle
self.measure_points = list(measure_points)
self.text_override = [""] * self.section_count
self.dimlinepos = Vec3(pos)
self.layout = None
def set_text(self, section: int, text: str) -> None:
"""
Set and override the text of the dimension text for the given dimension line section.
"""
self.text_override[section] = text
def _setup(self) -> None:
"""
Calc setup values and determines the point order of the dimension line points.
"""
self.measure_points = [Vec3(point) for point in self.measure_points]
dimlineray = ConstructionRay(self.dimlinepos, angle=radians(self.angle))
self.dimline_points = [
self._get_point_on_dimline(point, dimlineray)
for point in self.measure_points
]
self.point_order = self._indices_of_sorted_points(self.dimline_points)
self._build_vectors()
def _get_dimline_point(self, index: int) -> UVec:
"""
Get point on the dimension line, index runs left to right.
"""
return self.dimline_points[self.point_order[index]]
def _get_section_points(self, section: int) -> tuple[Vec3, Vec3]:
"""
Get start and end point on the dimension line of dimension section.
"""
return self._get_dimline_point(section), self._get_dimline_point(
section + 1
)
def _get_dimline_bounds(self) -> tuple[Vec3, Vec3]:
"""
Get the first and the last point of dimension line.
"""
return self._get_dimline_point(0), self._get_dimline_point(-1)
@property
def section_count(self) -> int:
"""count of dimline sections"""
return len(self.measure_points) - 1
@property
def point_count(self) -> int:
"""count of dimline points"""
return len(self.measure_points)
def render(self, layout: "GenericLayoutType") -> None:
"""build dimension line object with basic dxf entities"""
self._setup()
self._draw_dimline(layout)
if self.prop("dimextline"):
self._draw_extension_lines(layout)
self._draw_text(layout)
self._draw_ticks(layout)
@staticmethod
def _indices_of_sorted_points(points: Iterable[UVec]) -> list[int]:
"""get indices of points, for points sorted by x, y values"""
indexed_points = [(point, idx) for idx, point in enumerate(points)]
indexed_points.sort()
return [idx for point, idx in indexed_points]
def _build_vectors(self) -> None:
"""build unit vectors, parallel and normal to dimension line"""
point1, point2 = self._get_dimline_bounds()
self.parallel_vector = (Vec3(point2) - Vec3(point1)).normalize()
self.normal_vector = self.parallel_vector.orthogonal()
@staticmethod
def _get_point_on_dimline(point: UVec, dimray: ConstructionRay) -> Vec3:
"""get the measure target point projection on the dimension line"""
return dimray.intersect(dimray.orthogonal(point))
def _draw_dimline(self, layout: "GenericLayoutType") -> None:
"""build dimension line entity"""
start_point, end_point = self._get_dimline_bounds()
dimlineext = self.prop("dimlineext")
if dimlineext > 0:
start_point = start_point - (self.parallel_vector * dimlineext)
end_point = end_point + (self.parallel_vector * dimlineext)
attribs = {
"color": self.prop("dimlinecolor"),
"layer": self.prop("layer"),
}
layout.add_line(
start=start_point,
end=end_point,
dxfattribs=attribs,
)
def _draw_extension_lines(self, layout: "GenericLayoutType") -> None:
"""build the extension lines entities"""
dimextlinegap = self.prop("dimextlinegap")
attribs = {
"color": self.prop("dimlinecolor"),
"layer": self.prop("layer"),
}
for dimline_point, target_point in zip(
self.dimline_points, self.measure_points
):
if distance(dimline_point, target_point) > max(
dimextlinegap, DIMENSIONS_MIN_DISTANCE
):
direction_vector = (target_point - dimline_point).normalize()
target_point = target_point - (direction_vector * dimextlinegap)
layout.add_line(
start=dimline_point,
end=target_point,
dxfattribs=attribs,
)
def _draw_text(self, layout: "GenericLayoutType") -> None:
"""build the dimension value text entity"""
attribs = {
"height": self.prop("height"),
"color": self.prop("textcolor"),
"layer": self.prop("layer"),
"rotation": self.angle,
"style": self.prop("style"),
}
for section in range(self.section_count):
dimvalue_text = self._get_dimvalue_text(section)
insert_point = self._get_text_insert_point(section)
layout.add_text(
text=dimvalue_text,
dxfattribs=attribs,
).set_placement(
insert_point, align=TextEntityAlignment.MIDDLE_CENTER
)
def _get_dimvalue_text(self, section: int) -> str:
"""get the dimension value as text, distance from point1 to point2"""
override = self.text_override[section]
if len(override):
return override
point1, point2 = self._get_section_points(section)
dimvalue = distance(point1, point2) * self.prop("scale")
return self.format_dimtext(dimvalue)
def _get_text_insert_point(self, section: int) -> Vec3:
"""get the dimension value text insert point"""
point1, point2 = self._get_section_points(section)
dist = self.prop("height") / 2.0 + self.prop("textabove")
return lerp(point1, point2) + (self.normal_vector * dist)
def _draw_ticks(self, layout: "GenericLayoutType") -> None:
"""insert the dimension line ticks, (markers on the dimension line)"""
attribs = {
"xscale": self.prop("tickfactor"),
"yscale": self.prop("tickfactor"),
"layer": self.prop("layer"),
}
def add_tick(index: int, rotate: bool = False) -> None:
"""build the insert-entity for the tick block"""
attribs["rotation"] = self.angle + (180.0 if rotate else 0.0)
layout.add_blockref(
insert=self._get_dimline_point(index),
name=self.prop("tick"),
dxfattribs=attribs,
)
if self.prop("tick2x"):
for index in range(0, self.point_count - 1):
add_tick(index, False)
for index in range(1, self.point_count):
add_tick(index, True)
else:
for index in range(self.point_count):
add_tick(index, False)
class AngularDimension(_DimensionBase):
"""
Draw an angle dimensioning line at dimline pos from start to end, dimension text is the angle build of the three
points start-center-end.
"""
DEG = ANGLE_DEG
GRAD = ANGLE_GRAD
RAD = ANGLE_RAD
def __init__(
self,
pos: UVec,
center: UVec,
start: UVec,
end: UVec,
dimstyle: str = "angle.deg",
layer: Optional[str] = None,
roundval: Optional[int] = None,
):
"""
AngularDimension constructor.
Args:
pos: location as (x, y) tuple of dimension line, line goes through this point
center: center point as (x, y) tuple of angle
start: line from center to start is the first side of the angle
end: line from center to end is the second side of the angle
dimstyle: dimstyle name, 'Default' - style is the default value
layer: dimension line layer, override the default value of dimstyle
roundval: count of decimal places
"""
super().__init__(dimstyle, layer, roundval) # type: ignore
self.dimlinepos = Vec3(pos)
self.center = Vec3(center)
self.start = Vec3(start)
self.end = Vec3(end)
def _setup(self) -> None:
"""setup calculation values"""
self.pos_radius = distance(self.center, self.dimlinepos) # type: float
self.radius = distance(self.center, self.start) # type: float
self.start_vector = (self.start - self.center).normalize() # type: Vec3
self.end_vector = (self.end - self.center).normalize() # type: Vec3
self.start_angle = self.start_vector.angle # type: float
self.end_angle = self.end_vector.angle # type: float
def render(self, layout: "GenericLayoutType") -> None:
"""build dimension line object with basic dxf entities"""
self._setup()
self._draw_dimension_line(layout)
if self.prop("dimextline"):
self._draw_extension_lines(layout)
self._draw_dimension_text(layout)
self._draw_ticks(layout)
def _draw_dimension_line(self, layout: "GenericLayoutType") -> None:
"""draw the dimension line from start- to endangle."""
layout.add_arc(
radius=self.pos_radius,
center=self.center,
start_angle=degrees(self.start_angle),
end_angle=degrees(self.end_angle),
dxfattribs={
"layer": self.prop("layer"),
"color": self.prop("dimlinecolor"),
},
)
def _draw_extension_lines(self, layout: "GenericLayoutType") -> None:
"""build the extension lines entities"""
for vector in [self.start_vector, self.end_vector]:
layout.add_line(
start=self._get_extline_start(vector),
end=self._get_extline_end(vector),
dxfattribs={
"layer": self.prop("layer"),
"color": self.prop("dimextlinecolor"),
},
)
def _get_extline_start(self, vector: Vec3) -> Vec3:
return self.center + (vector * self.prop("dimextlinegap"))
def _get_extline_end(self, vector: Vec3) -> Vec3:
return self.center + (vector * self.pos_radius)
def _draw_dimension_text(self, layout: "GenericLayoutType") -> None:
attribs = {
"height": self.prop("height"),
"rotation": degrees(
(self.start_angle + self.end_angle) / 2 - pi / 2.0
),
"layer": self.prop("layer"),
"style": self.prop("style"),
"color": self.prop("textcolor"),
}
layout.add_text(
text=self._get_dimtext(),
dxfattribs=attribs,
).set_placement(
self._get_text_insert_point(),
align=TextEntityAlignment.MIDDLE_CENTER,
)
def _get_text_insert_point(self) -> Vec3:
midvector = ((self.start_vector + self.end_vector) / 2.0).normalize()
length = (
self.pos_radius + self.prop("textabove") + self.prop("height") / 2.0
)
return self.center + (midvector * length)
def _draw_ticks(self, layout: "GenericLayoutType") -> None:
attribs = {
"xscale": self.prop("tickfactor"),
"yscale": self.prop("tickfactor"),
"layer": self.prop("layer"),
}
for vector, mirror in [
(self.start_vector, False),
(self.end_vector, self.prop("tick2x")),
]:
insert_point = self.center + (vector * self.pos_radius)
rotation = vector.angle + pi / 2.0
attribs["rotation"] = degrees(rotation + (pi if mirror else 0.0))
layout.add_blockref(
insert=insert_point,
name=self.prop("tick"),
dxfattribs=attribs,
)
def _get_dimtext(self) -> str:
# set scale = ANGLE_DEG for degrees (circle = 360 deg)
# set scale = ANGLE_GRAD for grad(circle = 400 grad)
# set scale = ANGLE_RAD for rad(circle = 2*pi)
angle = (self.end_angle - self.start_angle) * self.prop("scale")
return self.format_dimtext(angle)
class ArcDimension(AngularDimension):
"""
Arc is defined by start- and endpoint on arc and the center point, or by three points lying on the arc if acr3points
is True. Measured length goes from start- to endpoint. The dimension line goes through the dimlinepos.
"""
def __init__(
self,
pos: UVec,
center: UVec,
start: UVec,
end: UVec,
arc3points: bool = False,
dimstyle: str = "Default",
layer: Optional[str] = None,
roundval: Optional[int] = None,
):
"""
Args:
pos: location as (x, y) tuple of dimension line, line goes through this point
center: center point of arc
start: start point of arc
end: end point of arc
arc3points: if **True** arc is defined by three points on the arc (center, start, end)
dimstyle: dimstyle name, 'Default' - style is the default value
layer: dimension line layer, override the default value of dimstyle
roundval: count of decimal places
"""
super().__init__(pos, center, start, end, dimstyle, layer, roundval)
self.arc3points = arc3points
def _setup(self) -> None:
super()._setup()
if self.arc3points:
self.center = center_of_3points_arc(
self.center, self.start, self.end
)
def _get_extline_start(self, vector: Vec3) -> Vec3:
return self.center + (
vector * (self.radius + self.prop("dimextlinegap"))
)
def _get_extline_end(self, vector: Vec3) -> Vec3:
return self.center + (vector * self.pos_radius)
def _get_dimtext(self) -> str:
arc_length = (
(self.end_angle - self.start_angle)
* self.radius
* self.prop("scale")
)
return self.format_dimtext(arc_length)
class RadialDimension(_DimensionBase):
"""
Draw a radius dimension line from `target` in direction of `center` with length drawing units. RadiusDimension has
a special tick!!
"""
def __init__(
self,
center: UVec,
target: UVec,
length: float = 1.0,
dimstyle: str = "Default",
layer: Optional[str] = None,
roundval: Optional[int] = None,
):
"""
Args:
center: center point of radius
target: target point of radius
length: length of radius arrow (drawing length)
dimstyle: dimstyle name, 'Default' - style is the default value
layer: dimension line layer, override the default value of dimstyle
roundval: count of decimal places
"""
super().__init__(dimstyle, layer, roundval) # type: ignore
self.center = Vec3(center)
self.target = Vec3(target)
self.length = float(length)
def _setup(self) -> None:
self.target_vector = (
self.target - self.center
).normalize() # type: Vec3
self.radius = distance(self.center, self.target) # type: float
def render(self, layout: "GenericLayoutType") -> None:
"""build dimension line object with basic dxf entities"""
self._setup()
self._draw_dimension_line(layout)
self._draw_dimension_text(layout)
self._draw_ticks(layout)
def _draw_dimension_line(self, layout: "GenericLayoutType") -> None:
start_point = self.center + (
self.target_vector * (self.radius - self.length)
)
layout.add_line(
start=start_point,
end=self.target,
dxfattribs={
"color": self.prop("dimlinecolor"),
"layer": self.prop("layer"),
},
)
def _draw_dimension_text(self, layout: "GenericLayoutType") -> None:
layout.add_text(
text=self._get_dimtext(),
dxfattribs={
"height": self.prop("height"),
"rotation": self.target_vector.angle_deg,
"layer": self.prop("layer"),
"style": self.prop("style"),
"color": self.prop("textcolor"),
},
).set_placement(
self._get_insert_point(), align=TextEntityAlignment.MIDDLE_RIGHT
)
def _get_insert_point(self) -> Vec3:
return self.target - (
self.target_vector * (self.length + self.prop("textabove"))
)
def _get_dimtext(self) -> str:
return self.format_dimtext(self.radius * self.prop("scale"))
def _draw_ticks(self, layout: "GenericLayoutType") -> None:
layout.add_blockref(
insert=self.target,
name="DIMTICK_RADIUS",
dxfattribs={
"rotation": self.target_vector.angle_deg + 180,
"xscale": self.prop("tickfactor"),
"yscale": self.prop("tickfactor"),
"layer": self.prop("layer"),
},
)
def center_of_3points_arc(point1: UVec, point2: UVec, point3: UVec) -> Vec2:
"""
Calc center point of 3 point arc. ConstructionCircle is defined by 3 points
on the circle: point1, point2 and point3.
"""
ray1 = ConstructionRay(point1, point2)
ray2 = ConstructionRay(point1, point3)
midpoint1 = lerp(point1, point2)
midpoint2 = lerp(point1, point3)
center_ray1 = ray1.orthogonal(midpoint1)
center_ray2 = ray2.orthogonal(midpoint2)
return center_ray1.intersect(center_ray2)
@@ -0,0 +1,5 @@
# Copyright (c) 2020-2021, Matthew Broadway
# License: MIT License
from .frontend import Frontend
from .properties import Properties, RenderContext, LayerProperties
@@ -0,0 +1,265 @@
# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
from __future__ import annotations
from abc import ABC, abstractmethod, ABCMeta
from typing import Optional, Iterable
import numpy as np
from typing_extensions import TypeAlias
import dataclasses
from ezdxf.addons.drawing.config import Configuration
from ezdxf.addons.drawing.properties import Properties, BackendProperties
from ezdxf.addons.drawing.type_hints import Color
from ezdxf.entities import DXFGraphic
from ezdxf.math import Vec2, Matrix44
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d, single_paths
BkPath2d: TypeAlias = NumpyPath2d
BkPoints2d: TypeAlias = NumpyPoints2d
# fmt: off
_IMAGE_FLIP_MATRIX = [
1.0, 0.0, 0.0, 0.0,
0.0, -1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 999, 0.0, 1.0 # index 13: 999 = image height
]
# fmt: on
@dataclasses.dataclass
class ImageData:
"""Image data.
Attributes:
image: an array of RGBA pixels
transform: the transformation to apply to the image when drawing
(the transform from pixel coordinates to wcs)
pixel_boundary_path: boundary path vertices in pixel coordinates, the image
coordinate system has an inverted y-axis and the top-left corner is (0, 0)
remove_outside: remove image outside the clipping boundary if ``True`` otherwise
remove image inside the clipping boundary
"""
image: np.ndarray
transform: Matrix44
pixel_boundary_path: NumpyPoints2d
use_clipping_boundary: bool = False
remove_outside: bool = True
def image_size(self) -> tuple[int, int]:
"""Returns the image size as tuple (width, height)."""
image_height, image_width, *_ = self.image.shape
return image_width, image_height
def flip_matrix(self) -> Matrix44:
"""Returns the transformation matrix to align the image coordinate system with
the WCS.
"""
_, image_height = self.image_size()
_IMAGE_FLIP_MATRIX[13] = image_height
return Matrix44(_IMAGE_FLIP_MATRIX)
class BackendInterface(ABC):
"""Public interface for 2D rendering backends."""
@abstractmethod
def configure(self, config: Configuration) -> None:
raise NotImplementedError
@abstractmethod
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
# gets the full DXF properties information
raise NotImplementedError
@abstractmethod
def exit_entity(self, entity: DXFGraphic) -> None:
raise NotImplementedError
@abstractmethod
def set_background(self, color: Color) -> None:
raise NotImplementedError
@abstractmethod
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
raise NotImplementedError
@abstractmethod
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
raise NotImplementedError
@abstractmethod
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
raise NotImplementedError
@abstractmethod
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
raise NotImplementedError
@abstractmethod
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
raise NotImplementedError
@abstractmethod
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
raise NotImplementedError
@abstractmethod
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
raise NotImplementedError
@abstractmethod
def clear(self) -> None:
raise NotImplementedError
@abstractmethod
def finalize(self) -> None:
raise NotImplementedError
class Backend(BackendInterface, metaclass=ABCMeta):
def __init__(self) -> None:
self.entity_stack: list[tuple[DXFGraphic, Properties]] = []
self.config: Configuration = Configuration()
def configure(self, config: Configuration) -> None:
self.config = config
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
# gets the full DXF properties information
self.entity_stack.append((entity, properties))
def exit_entity(self, entity: DXFGraphic) -> None:
e, p = self.entity_stack.pop()
assert e is entity, "entity stack mismatch"
@property
def current_entity(self) -> Optional[DXFGraphic]:
"""Obtain the current entity being drawn"""
return self.entity_stack[-1][0] if self.entity_stack else None
@abstractmethod
def set_background(self, color: Color) -> None:
raise NotImplementedError
@abstractmethod
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
"""Draw a real dimensionless point, because not all backends support
zero-length lines!
"""
raise NotImplementedError
@abstractmethod
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
raise NotImplementedError
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
"""Fast method to draw a bunch of solid lines with the same properties."""
# Must be overridden by the backend to gain a performance benefit.
# This is the default implementation to ensure compatibility with
# existing backends.
for s, e in lines:
if e.isclose(s):
self.draw_point(s, properties)
else:
self.draw_line(s, e, properties)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
"""Draw an outline path (connected string of line segments and Bezier
curves).
The :meth:`draw_path` implementation is a fall-back implementation
which approximates Bezier curves by flattening as line segments.
Backends can override this method if better path drawing functionality
is available for that backend.
"""
if len(path):
vertices = iter(
path.flattening(distance=self.config.max_flattening_distance)
)
prev = next(vertices)
for vertex in vertices:
self.draw_line(prev, vertex, properties)
prev = vertex
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
"""Draw multiple filled paths (connected string of line segments and
Bezier curves).
The current implementation passes these paths to the backend, all backends
included in ezdxf handle holes by the even-odd method. If a backend requires
oriented paths (exterior paths in counter-clockwise and holes in clockwise
orientation) use the function :func:`oriented_paths` to separate and orient the
input paths.
The default implementation draws all paths as filled polygons.
Args:
paths: sequence of paths
properties: HATCH properties
"""
for path in paths:
self.draw_filled_polygon(
BkPoints2d(
path.flattening(distance=self.config.max_flattening_distance)
),
properties,
)
@abstractmethod
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
"""Fill a polygon whose outline is defined by the given points.
Used to draw entities with simple outlines where :meth:`draw_path` may
be an inefficient way to draw such a polygon.
"""
raise NotImplementedError
@abstractmethod
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
"""Draw an image with the given pixels."""
raise NotImplementedError
@abstractmethod
def clear(self) -> None:
"""Clear the canvas. Does not reset the internal state of the backend.
Make sure that the previous drawing is finished before clearing.
"""
raise NotImplementedError
def finalize(self) -> None:
pass
def oriented_paths(paths: Iterable[BkPath2d]) -> tuple[list[BkPath2d], list[BkPath2d]]:
"""Separate paths into exterior paths and holes. Exterior paths are oriented
counter-clockwise, holes are oriented clockwise.
"""
from ezdxf.path import winding_deconstruction, make_polygon_structure
polygons = make_polygon_structure(single_paths(paths))
external_paths: list[BkPath2d]
holes: list[BkPath2d]
external_paths, holes = winding_deconstruction(polygons)
for p in external_paths:
p.counter_clockwise()
for p in holes:
p.clockwise()
return external_paths, holes
@@ -0,0 +1,292 @@
# Copyright (c) 2021-2024, Matthew Broadway
# License: MIT License
from __future__ import annotations
from typing import Optional
import warnings
import dataclasses
from dataclasses import dataclass
from enum import Enum, auto
from ezdxf import disassemble
from ezdxf.enums import Measurement
from .type_hints import Color
class LinePolicy(Enum):
"""This enum is used to define how to render linetypes.
.. note::
Text and shapes in linetypes are not supported.
Attributes:
SOLID: draw all lines as solid regardless of the linetype style
ACCURATE: render styled lines as accurately as possible
APPROXIMATE: ignored since v0.18.1 - uses always ACCURATE by default
"""
SOLID = auto()
APPROXIMATE = auto() # ignored since v0.18.1
ACCURATE = auto()
class ProxyGraphicPolicy(Enum):
"""The action to take when an entity with a proxy graphic is encountered
.. note::
To get proxy graphics support proxy graphics have to be loaded:
Set the global option :attr:`ezdxf.options.load_proxy_graphics` to
``True``, which is the default value.
This can not prevent drawing proxy graphic inside of blocks,
because this is beyond the domain of the drawing add-on!
Attributes:
IGNORE: do not display proxy graphics (skip_entity will be called instead)
SHOW: if the entity cannot be rendered directly (e.g. if not implemented)
but a proxy is present: display the proxy
PREFER: display proxy graphics even for entities where direct rendering
is available
"""
IGNORE = auto()
SHOW = auto()
PREFER = auto()
class HatchPolicy(Enum):
"""The action to take when a HATCH entity is encountered
Attributes:
NORMAL: render pattern and solid fillings
IGNORE: do not show HATCH entities at all
SHOW_OUTLINE: show only the outline of HATCH entities
SHOW_SOLID: show HATCH entities as solid filling regardless of the pattern
"""
NORMAL = auto()
IGNORE = auto()
SHOW_OUTLINE = auto()
SHOW_SOLID = auto()
SHOW_APPROXIMATE_PATTERN = auto() # ignored since v0.18.1 == NORMAL
class LineweightPolicy(Enum):
"""This enum is used to define how to determine the lineweight.
Attributes:
ABSOLUTE: in mm as resolved by the :class:`Frontend` class
RELATIVE: lineweight is relative to page size
RELATIVE_FIXED: fixed lineweight relative to page size for all strokes
"""
ABSOLUTE = auto()
# set fixed lineweight for all strokes in absolute mode:
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
# set Configuration.lineweight_scaling to 0
# The RELATIVE policy is a backend feature and is not supported by all backends!
RELATIVE = auto()
RELATIVE_FIXED = auto()
class ColorPolicy(Enum):
"""This enum is used to define how to determine the line/fill color.
Attributes:
COLOR: as resolved by the :class:`Frontend` class
COLOR_SWAP_BW: as resolved by the :class:`Frontend` class but swaps black and white
COLOR_NEGATIVE: invert all colors
MONOCHROME: maps all colors to gray scale in range [0%, 100%]
MONOCHROME_DARK_BG: maps all colors to gray scale in range [30%, 100%], brightens
colors for dark backgrounds
MONOCHROME_LIGHT_BG: maps all colors to gray scale in range [0%, 70%], darkens
colors for light backgrounds
BLACK: maps all colors to black
WHITE: maps all colors to white
CUSTOM: maps all colors to custom color :attr:`Configuration.custom_fg_color`
"""
COLOR = auto()
COLOR_SWAP_BW = auto()
COLOR_NEGATIVE = auto()
MONOCHROME = auto()
MONOCHROME_DARK_BG = auto()
MONOCHROME_LIGHT_BG = auto()
BLACK = auto()
WHITE = auto()
CUSTOM = auto()
class BackgroundPolicy(Enum):
"""This enum is used to define the background color.
Attributes:
DEFAULT: as resolved by the :class:`Frontend` class
WHITE: white background
BLACK: black background
PAPERSPACE: default paperspace background
MODELSPACE: default modelspace background
OFF: fully transparent background
CUSTOM: custom background color by :attr:`Configuration.custom_bg_color`
"""
DEFAULT = auto()
WHITE = auto()
BLACK = auto()
PAPERSPACE = auto()
MODELSPACE = auto()
OFF = auto()
CUSTOM = auto()
class TextPolicy(Enum):
"""This enum is used to define the text rendering.
Attributes:
FILLING: text is rendered as solid filling (default)
OUTLINE: text is rendered as outline paths
REPLACE_RECT: replace text by a rectangle
REPLACE_FILL: replace text by a filled rectangle
IGNORE: ignore text entirely
"""
FILLING = auto()
OUTLINE = auto()
REPLACE_RECT = auto()
REPLACE_FILL = auto()
IGNORE = auto()
class ImagePolicy(Enum):
"""This enum is used to define the image rendering.
Attributes:
DISPLAY: display images as they would appear in a regular CAD application
RECT: display images as rectangles
MISSING: images are always rendered as-if they are missing (rectangle + path text)
PROXY: images are rendered using their proxy representations (rectangle)
IGNORE: ignore images entirely
"""
DISPLAY = auto()
RECT = auto()
MISSING = auto()
PROXY = auto()
IGNORE = auto()
@dataclass(frozen=True)
class Configuration:
"""Configuration options for the :mod:`drawing` add-on.
Attributes:
pdsize: the size to draw POINT entities (in drawing units)
set to None to use the $PDSIZE value from the dxf document header
======= ====================================================
0 5% of draw area height
<0 Specifies a percentage of the viewport size
>0 Specifies an absolute size
None use the $PDMODE value from the dxf document header
======= ====================================================
pdmode: point styling mode (see POINT documentation)
see :class:`~ezdxf.entities.Point` class documentation
measurement: whether to use metric or imperial units as enum :class:`ezdxf.enums.Measurement`
======= ======================================================
0 use imperial units (in, ft, yd, ...)
1 use metric units (ISO meters)
None use the $MEASUREMENT value from the dxf document header
======= ======================================================
show_defpoints: whether to show or filter out POINT entities on the defpoints layer
proxy_graphic_policy: the action to take when a proxy graphic is encountered
line_policy: the method to use when drawing styled lines (eg dashed,
dotted etc)
hatch_policy: the method to use when drawing HATCH entities
infinite_line_length: the length to use when drawing infinite lines
lineweight_scaling:
multiplies every lineweight by this factor; set this factor to 0.0 for a
constant minimum line width defined by the :attr:`min_lineweight` setting
for all lineweights;
the correct DXF lineweight often looks too thick in SVG, so setting a
factor < 1 can improve the visual appearance
min_lineweight: the minimum line width in 1/300 inch; set to ``None`` for
let the backend choose.
min_dash_length: the minimum length for a dash when drawing a styled line
(default value is arbitrary)
max_flattening_distance: Max flattening distance in drawing units
see Path.flattening documentation.
The backend implementation should calculate an appropriate value,
like 1 screen- or paper pixel on the output medium, but converted
into drawing units. Sets Path() approximation accuracy
circle_approximation_count: Approximate a full circle by `n` segments, arcs
have proportional less segments. Only used for approximation of arcs
in banded polylines.
hatching_timeout: hatching timeout for a single entity, very dense
hatching patterns can cause a very long execution time, the default
timeout for a single entity is 30 seconds.
min_hatch_line_distance: minimum hatch line distance to render, narrower pattern
lines are rendered as solid filling
color_policy:
custom_fg_color: Used for :class:`ColorPolicy.custom` policy, custom foreground
color as "#RRGGBBAA" color string (RGB+alpha)
background_policy:
custom_bg_color: Used for :class:`BackgroundPolicy.custom` policy, custom
background color as "#RRGGBBAA" color string (RGB+alpha)
lineweight_policy:
text_policy:
image_policy: the method for drawing IMAGE entities
"""
pdsize: Optional[int] = None # use $PDSIZE from HEADER section
pdmode: Optional[int] = None # use $PDMODE from HEADER section
measurement: Optional[Measurement] = None
show_defpoints: bool = False
proxy_graphic_policy: ProxyGraphicPolicy = ProxyGraphicPolicy.SHOW
line_policy: LinePolicy = LinePolicy.ACCURATE
hatch_policy: HatchPolicy = HatchPolicy.NORMAL
infinite_line_length: float = 20
lineweight_scaling: float = 1.0
min_lineweight: Optional[float] = None
min_dash_length: float = 0.1
max_flattening_distance: float = disassemble.Primitive.max_flattening_distance
circle_approximation_count: int = 128
hatching_timeout: float = 30.0
# Keep value in sync with ezdxf.render.hatching.MIN_HATCH_LINE_DISTANCE
min_hatch_line_distance: float = 1e-4
color_policy: ColorPolicy = ColorPolicy.COLOR
custom_fg_color: Color = "#000000"
background_policy: BackgroundPolicy = BackgroundPolicy.DEFAULT
custom_bg_color: Color = "#ffffff"
lineweight_policy: LineweightPolicy = LineweightPolicy.ABSOLUTE
text_policy: TextPolicy = TextPolicy.FILLING
image_policy: ImagePolicy = ImagePolicy.DISPLAY
@staticmethod
def defaults() -> Configuration:
warnings.warn(
"use Configuration() instead of Configuration.defaults()",
DeprecationWarning,
)
return Configuration()
def with_changes(self, **kwargs) -> Configuration:
"""Returns a new frozen :class:`Configuration` object with modified values."""
params = dataclasses.asdict(self)
for k, v in kwargs.items():
params[k] = v
return Configuration(**params)
@@ -0,0 +1,52 @@
# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable
from ezdxf.math import Vec2
from .properties import BackendProperties
from .backend import Backend, BkPath2d, BkPoints2d, ImageData
from .config import Configuration
class BasicBackend(Backend):
"""The basic backend has no draw_path() support and approximates all curves
by lines.
"""
def __init__(self):
super().__init__()
self.collector = []
self.configure(Configuration())
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.collector.append(("point", pos, properties))
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.collector.append(("line", start, end, properties))
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
self.collector.append(("filled_polygon", points, properties))
def draw_image(
self, image_data: ImageData, properties: BackendProperties
) -> None:
self.collector.append(("image", image_data, properties))
def set_background(self, color: str) -> None:
self.collector.append(("bgcolor", color))
def clear(self) -> None:
self.collector = []
class PathBackend(BasicBackend):
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
self.collector.append(("path", path, properties))
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
self.collector.append(("filled_path", tuple(paths), properties))
@@ -0,0 +1,15 @@
# Copyright (c) 2020-2021, Matthew Broadway
# License: MIT License
from __future__ import annotations
from ezdxf.addons.drawing.backend import BackendInterface
from ezdxf.addons.drawing.type_hints import Color
from ezdxf.math import Vec3
def draw_rect(points: list[Vec3], color: Color, out: BackendInterface):
from ezdxf.addons.drawing.properties import BackendProperties
props = BackendProperties(color=color)
for a, b in zip(points, points[1:]):
out.draw_line(a, b, props)
@@ -0,0 +1,223 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, TYPE_CHECKING, no_type_check
from functools import lru_cache
import enum
import numpy as np
from ezdxf import colors
from ezdxf.lldxf.const import VALID_DXF_LINEWEIGHTS
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
from ezdxf.path import to_splines_and_polylines, to_hatches
from ezdxf.layouts import BaseLayout
from .type_hints import Color
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import Configuration
from .properties import BackendProperties
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import Solid
class ColorMode(enum.Enum):
"""This enum is used to define the color output mode of the :class:`DXFBackend`.
Attributes:
ACI: the color is set as :ref:`ACI` and assigned by layer
RGB: the color is set as RGB true color value
"""
# Use color index as primary color
ACI = enum.auto()
# Use always the RGB value
RGB = enum.auto()
DARK_COLOR_THRESHOLD = 0.2
RGB_BLACK = colors.RGB(0, 0, 0)
BYLAYER = 256
class DXFBackend(BackendInterface):
"""The :class:`DXFBackend` creates simple DXF files of POINT, LINE, LWPOLYLINE and
HATCH entities. This backend does ot need any additional packages.
Args:
layout: a DXF :class:`~ezdxf.layouts.BaseLayout`
color_mode: see :class:`ColorMode`
"""
def __init__(
self, layout: BaseLayout, color_mode: ColorMode = ColorMode.RGB
) -> None:
assert layout.doc is not None, "valid DXF document required"
super().__init__()
self.layout = layout
self.doc = layout.doc
self.color_mode = color_mode
self.bg_color = RGB_BLACK
self.is_dark_bg = True
self._layers: dict[int, str] = dict()
self._dxfattribs: dict[int, dict] = dict()
def set_background(self, color: Color) -> None:
self.bg_color = colors.RGB.from_hex(color)
self.is_dark_bg = self.bg_color.luminance < DARK_COLOR_THRESHOLD
def get_layer_name(self, pen: int) -> str:
try:
return self._layers[pen]
except KeyError:
pass
layer_name = f"PEN_{pen:03d}"
self._layers[pen] = layer_name
if not self.doc.layers.has_entry(layer_name):
self.doc.layers.add(layer_name, color=pen)
return layer_name
def resolve_properties(self, properties: BackendProperties) -> dict:
key = hash(properties)
try:
return self._dxfattribs[key]
except KeyError:
pass
rgb = properties.rgb
pen = properties.pen
if pen < 1 or pen > 255:
pen = 7
aci = pen
if self.color_mode == ColorMode.ACI:
aci = BYLAYER
attribs = {
"color": aci,
"layer": self.get_layer_name(pen),
"lineweight": make_lineweight(properties.lineweight),
}
if self.color_mode == ColorMode.RGB:
attribs["true_color"] = colors.rgb2int(rgb)
alpha = properties.color[7:9]
if alpha:
try:
f = int(alpha, 16) / 255
except ValueError:
pass
else:
attribs["transparency"] = colors.float2transparency(f)
self._dxfattribs[key] = attribs
return attribs
def set_solid_fill(self, hatch, properties: BackendProperties) -> None:
rgb: colors.RGB | None = None
aci = BYLAYER
if self.color_mode == ColorMode.RGB:
rgb = properties.rgb
aci = properties.pen
hatch.set_solid_fill(color=aci, style=0, rgb=rgb)
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.layout.add_point(pos, dxfattribs=self.resolve_properties(properties))
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.layout.add_line(start, end, dxfattribs=self.resolve_properties(properties))
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
lines = list(lines)
if len(lines) == 0:
return
attribs = self.resolve_properties(properties)
for start, end in lines:
self.layout.add_line(start, end, dxfattribs=attribs)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
attribs = self.resolve_properties(properties)
if path.has_curves:
for entity in to_splines_and_polylines(path, dxfattribs=attribs): # type: ignore
self.layout.add_entity(entity)
else:
self.layout.add_lwpolyline(path.control_vertices(), dxfattribs=attribs)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
attribs = self.resolve_properties(properties)
py_paths = [p.to_path() for p in paths]
for hatch in to_hatches(py_paths, dxfattribs=attribs):
self.layout.add_entity(hatch)
self.set_solid_fill(hatch, properties)
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
hatch = self.layout.add_hatch(dxfattribs=self.resolve_properties(properties))
hatch.paths.add_polyline_path(points.vertices(), is_closed=True)
self.set_solid_fill(hatch, properties)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
pass # TODO: not implemented
def configure(self, config: Configuration) -> None:
pass
def clear(self) -> None:
pass
def finalize(self) -> None:
pass
def enter_entity(self, entity, properties) -> None:
pass
def exit_entity(self, entity) -> None:
pass
def alpha_to_transparency(alpha: int) -> float:
return colors.float2transparency(alpha / 255)
@lru_cache(maxsize=None)
def make_lineweight(width: float) -> int:
width_int = int(width * 100)
for lw in VALID_DXF_LINEWEIGHTS:
if width_int <= lw:
return lw
return VALID_DXF_LINEWEIGHTS[-1]
@no_type_check
def update_extents(doc: Drawing, bbox: BoundingBox2d) -> None:
doc.header["$EXTMIN"] = (bbox.extmin.x, bbox.extmin.y, 0)
doc.header["$EXTMAX"] = (bbox.extmax.x, bbox.extmax.y, 0)
def setup_paperspace(doc: Drawing, bbox: BoundingBox2d):
psp_size = bbox.size / 40.0 # plu to mm
psp_center = psp_size * 0.5
psp = doc.paperspace()
psp.page_setup(size=(psp_size.x, psp_size.y), margins=(0, 0, 0, 0), units="mm")
psp.add_viewport(
center=psp_center,
size=(psp_size.x, psp_size.y),
view_center_point=bbox.center,
view_height=bbox.size.y,
status=2,
)
def add_background(msp: BaseLayout, bbox: BoundingBox2d, color: colors.RGB) -> Solid:
v = bbox.rect_vertices()
bg = msp.add_solid(
[v[0], v[1], v[3], v[2]], dxfattribs={"true_color": colors.rgb2int(color)}
)
return bg
@@ -0,0 +1,228 @@
import pathlib
import sys
from abc import ABC, abstractmethod
import subprocess
import os
import platform
from ezdxf.addons.drawing.backend import BackendInterface
class FileOutputRenderBackend(ABC):
def __init__(self, dpi: float) -> None:
self._dpi = dpi
@abstractmethod
def supported_formats(self) -> list[tuple[str, str]]:
raise NotImplementedError
@abstractmethod
def default_format(self) -> str:
raise NotImplementedError
@abstractmethod
def backend(self) -> BackendInterface:
raise NotImplementedError
@abstractmethod
def save(self, output: pathlib.Path) -> None:
raise NotImplementedError
class MatplotlibFileOutput(FileOutputRenderBackend):
def __init__(self, dpi: float) -> None:
super().__init__(dpi)
try:
import matplotlib.pyplot as plt
except ImportError:
raise ImportError("Matplotlib not found") from None
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
self._plt = plt
self._fig = plt.figure()
self._ax = self._fig.add_axes((0, 0, 1, 1))
self._backend = MatplotlibBackend(self._ax)
def supported_formats(self) -> list[tuple[str, str]]:
return list(self._fig.canvas.get_supported_filetypes().items())
def default_format(self) -> str:
return "png"
def backend(self) -> BackendInterface:
return self._backend
def save(self, output: pathlib.Path) -> None:
self._fig.savefig(output, dpi=self._dpi)
self._plt.close(self._fig)
class PyQtFileOutput(FileOutputRenderBackend):
def __init__(self, dpi: float) -> None:
super().__init__(dpi)
try:
from ezdxf.addons.xqt import QtCore, QtGui, QtWidgets
from ezdxf.addons.drawing.pyqt import PyQtBackend
except ImportError:
raise ImportError("PyQt not found") from None
self._qc = QtCore
self._qg = QtGui
self._qw = QtWidgets
self._app = QtWidgets.QApplication(sys.argv)
self._scene = QtWidgets.QGraphicsScene()
self._backend = PyQtBackend()
self._backend.set_scene(self._scene)
def supported_formats(self) -> list[tuple[str, str]]:
# https://doc.qt.io/qt-6/qimage.html#reading-and-writing-image-files
return [
("bmp", "Windows Bitmap"),
("jpg", "Joint Photographic Experts Group"),
("jpeg", "Joint Photographic Experts Group"),
("png", "Portable Network Graphics"),
("ppm", "Portable Pixmap"),
("xbm", "X11 Bitmap"),
("xpm", "X11 Pixmap"),
("svg", "Scalable Vector Graphics"),
]
def default_format(self) -> str:
return "png"
def backend(self) -> BackendInterface:
return self._backend
def save(self, output: pathlib.Path) -> None:
if output.suffix.lower() == ".svg":
from PySide6.QtSvg import QSvgGenerator
generator = QSvgGenerator()
generator.setFileName(str(output))
generator.setResolution(int(self._dpi))
scene_rect = self._scene.sceneRect()
output_size = self._qc.QSize(
round(scene_rect.size().width()), round(scene_rect.size().height())
)
generator.setSize(output_size)
generator.setViewBox(
self._qc.QRect(0, 0, output_size.width(), output_size.height())
)
painter = self._qg.QPainter()
transform = self._qg.QTransform()
transform.scale(1, -1)
transform.translate(0, -output_size.height())
painter.begin(generator)
painter.setWorldTransform(transform, combine=True)
painter.setRenderHint(self._qg.QPainter.RenderHint.Antialiasing)
self._scene.render(painter)
painter.end()
else:
view = self._qw.QGraphicsView(self._scene)
view.setRenderHint(self._qg.QPainter.RenderHint.Antialiasing)
sizef: QRectF = self._scene.sceneRect() * self._dpi / 92 # type: ignore
image = self._qg.QImage(
self._qc.QSize(round(sizef.width()), round(sizef.height())),
self._qg.QImage.Format.Format_ARGB32,
)
painter = self._qg.QPainter(image)
painter.setRenderHint(self._qg.QPainter.RenderHint.Antialiasing)
painter.fillRect(image.rect(), self._scene.backgroundBrush())
self._scene.render(painter)
painter.end()
image.mirror(False, True)
image.save(str(output))
class MuPDFFileOutput(FileOutputRenderBackend):
def __init__(self, dpi: float) -> None:
super().__init__(dpi)
from ezdxf.addons.drawing.pymupdf import PyMuPdfBackend, is_pymupdf_installed
if not is_pymupdf_installed:
raise ImportError("PyMuPDF not found")
self._backend = PyMuPdfBackend()
def supported_formats(self) -> list[tuple[str, str]]:
# https://pymupdf.readthedocs.io/en/latest/pixmap.html#pixmapoutput
return [
("pdf", "Portable Document Format"),
("svg", "Scalable Vector Graphics"),
("jpg", "Joint Photographic Experts Group"),
("jpeg", "Joint Photographic Experts Group"),
("pam", "Portable Arbitrary Map"),
("pbm", "Portable Bitmap"),
("pgm", "Portable Graymap"),
("png", "Portable Network Graphics"),
("pnm", "Portable Anymap"),
("ppm", "Portable Pixmap (no alpha channel)"),
("ps", "Adobe PostScript Image"),
("psd", "Adobe Photoshop Document"),
]
def default_format(self) -> str:
return "pdf"
def backend(self) -> BackendInterface:
return self._backend
def save(self, output: pathlib.Path) -> None:
from ezdxf.addons.drawing import layout
backend = self._backend.get_replay(layout.Page(0, 0))
if output.suffix == ".pdf":
output.write_bytes(backend.get_pdf_bytes())
elif output.suffix == ".svg":
output.write_text(backend.get_svg_image())
else:
pixmap = backend.get_pixmap(int(self._dpi), alpha=True)
pixmap.save(str(output))
class SvgFileOutput(FileOutputRenderBackend):
def __init__(self, dpi: float) -> None:
super().__init__(dpi)
from ezdxf.addons.drawing.svg import SVGBackend
self._backend = SVGBackend()
def supported_formats(self) -> list[tuple[str, str]]:
return [("svg", "Scalable Vector Graphics")]
def default_format(self) -> str:
return "svg"
def backend(self) -> BackendInterface:
return self._backend
def save(self, output: pathlib.Path) -> None:
from ezdxf.addons.drawing import layout
output.write_text(self._backend.get_string(layout.Page(0, 0)))
def open_file(path: pathlib.Path) -> None:
"""open the given path in the default application"""
system = platform.system()
if system == "Darwin":
subprocess.call(
["open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
elif system == "Windows":
os.startfile(str(path)) # type: ignore
else:
subprocess.call(
["xdg-open", str(path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Iterator
from ezdxf.entities import DXFGraphic, DXFEntity
from ezdxf.lldxf import const
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.protocols import SupportsVirtualEntities
from ezdxf.entities.copy import default_copy, CopyNotSupported
class DXFGraphicProxy(DXFGraphic):
"""DO NOT USE THIS WRAPPER AS REAL DXF ENTITY OUTSIDE THE DRAWING ADD-ON!"""
def __init__(self, entity: DXFEntity):
super().__init__()
self.entity = entity
self.dxf = self._setup_dxf_namespace(entity)
def _setup_dxf_namespace(self, entity):
# copy DXF namespace - modifications do not effect the wrapped entity
dxf = entity.dxf.copy(self)
# setup mandatory DXF attributes without default values like layer:
for k, v in self.DEFAULT_ATTRIBS.items():
if not dxf.hasattr(k):
dxf.set(k, v)
return dxf
def dxftype(self) -> str:
return self.entity.dxftype()
def __virtual_entities__(self) -> Iterator[DXFGraphic]:
"""Implements the SupportsVirtualEntities protocol."""
if isinstance(self.entity, SupportsVirtualEntities):
return self.entity.__virtual_entities__()
if hasattr(self.entity, "virtual_entities"):
return self.entity.virtual_entities()
return iter([])
def virtual_entities(self) -> Iterable[DXFGraphic]:
return self.__virtual_entities__()
def copy(self, copy_strategy=default_copy) -> DXFGraphicProxy:
raise CopyNotSupported(f"Copying of DXFGraphicProxy() not supported.")
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
# prevent dxf export
return False
@@ -0,0 +1,549 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Sequence, no_type_check
import copy
import numpy as np
from ezdxf import colors
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
from ezdxf.path import Command
from .type_hints import Color
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import Configuration, LineweightPolicy
from .properties import BackendProperties
from . import layout, recorder
__all__ = ["PlotterBackend"]
SEMICOLON = ord(";")
PRELUDE = b"%0B;IN;BP;"
EPILOG = b"PU;PA0,0;"
FLATTEN_MAX = 10 # plot units
MM_TO_PLU = 40 # 40 plu = 1mm
DEFAULT_PEN = 0
WHITE = colors.RGB(255, 255, 255)
BLACK = colors.RGB(0, 0, 0)
MAX_FLATTEN = 10
# comparing Command.<attrib> to ints is very slow
CMD_MOVE_TO = int(Command.MOVE_TO)
CMD_LINE_TO = int(Command.LINE_TO)
CMD_CURVE3_TO = int(Command.CURVE3_TO)
CMD_CURVE4_TO = int(Command.CURVE4_TO)
class PlotterBackend(recorder.Recorder):
"""The :class:`PlotterBackend` creates HPGL/2 plot files for output on raster
plotters. This backend does not need any additional packages. This backend support
content cropping at page margins.
The plot files are tested by the plot file viewer `ViewCompanion Standard`_
but not on real hardware - please use with care and give feedback.
.. _ViewCompanion Standard: http://www.softwarecompanions.com/
"""
def __init__(self) -> None:
super().__init__()
def get_bytes(
self,
page: layout.Page,
*,
settings: layout.Settings = layout.Settings(),
render_box: BoundingBox2d | None = None,
curves=True,
decimal_places: int = 1,
base=64,
) -> bytes:
"""Returns the HPGL/2 data as bytes.
Args:
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
render_box: set explicit region to render, default is content bounding box
curves: use Bèzier curves for HPGL/2 output
decimal_places: HPGL/2 output precision, less decimal places creates smaller
files but for the price of imprecise curves (text)
base: base for polyline encoding, 32 for 7 bit encoding or 64 for 8 bit encoding
"""
top_origin = False
settings = copy.copy(settings)
# This player changes the original recordings!
player = self.player()
if render_box is None:
render_box = player.bbox()
# the page origin (0, 0) is in the bottom-left corner.
output_layout = layout.Layout(render_box, flip_y=False)
page = output_layout.get_final_page(page, settings)
if page.width == 0 or page.height == 0:
return b"" # empty page
# DXF coordinates are mapped to integer coordinates (plu) in the first
# quadrant: 40 plu = 1mm
settings.output_coordinate_space = (
max(page.width_in_mm, page.height_in_mm) * MM_TO_PLU
)
# transform content to the output coordinates space:
m = output_layout.get_placement_matrix(
page, settings=settings, top_origin=top_origin
)
player.transform(m)
if settings.crop_at_margins:
p1, p2 = page.get_margin_rect(top_origin=top_origin) # in mm
# scale factor to map page coordinates to output space coordinates:
output_scale = settings.page_output_scale_factor(page)
max_sagitta = 0.1 * MM_TO_PLU # curve approximation 0.1 mm
# crop content inplace by the margin rect:
player.crop_rect(p1 * output_scale, p2 * output_scale, max_sagitta)
backend = _RenderBackend(
page,
settings=settings,
curves=curves,
decimal_places=decimal_places,
base=base,
)
player.replay(backend)
return backend.get_bytes()
def compatible(
self, page: layout.Page, settings: layout.Settings = layout.Settings()
) -> bytes:
"""Returns the HPGL/2 data as 7-bit encoded bytes curves as approximated
polylines and coordinates are rounded to integer values.
Has often the smallest file size and should be compatible to all output devices
but has a low quality text rendering.
"""
return self.get_bytes(
page, settings=settings, curves=False, decimal_places=0, base=32
)
def low_quality(
self, page: layout.Page, settings: layout.Settings = layout.Settings()
) -> bytes:
"""Returns the HPGL/2 data as 8-bit encoded bytes, curves as Bézier
curves and coordinates are rounded to integer values.
Has a smaller file size than normal quality and the output device must support
8-bit encoding and Bèzier curves.
"""
return self.get_bytes(
page, settings=settings, curves=True, decimal_places=0, base=64
)
def normal_quality(
self, page: layout.Page, settings: layout.Settings = layout.Settings()
) -> bytes:
"""Returns the HPGL/2 data as 8-bit encoded bytes, curves as Bézier
curves and coordinates are floats rounded to one decimal place.
Has a smaller file size than high quality and the output device must support
8-bit encoding, Bèzier curves and fractional coordinates.
"""
return self.get_bytes(
page, settings=settings, curves=True, decimal_places=1, base=64
)
def high_quality(
self, page: layout.Page, settings: layout.Settings = layout.Settings()
) -> bytes:
"""Returns the HPGL/2 data as 8-bit encoded bytes and all curves as Bézier
curves and coordinates are floats rounded to two decimal places.
Has the largest file size and the output device must support 8-bit encoding,
Bèzier curves and fractional coordinates.
"""
return self.get_bytes(
page, settings=settings, curves=True, decimal_places=2, base=64
)
class PenTable:
def __init__(self, max_pens: int = 64) -> None:
self.pens: dict[int, colors.RGB] = dict()
self.max_pens = int(max_pens)
def __contains__(self, index: int) -> bool:
return index in self.pens
def __getitem__(self, index: int) -> colors.RGB:
return self.pens[index]
def add_pen(self, index: int, color: colors.RGB):
self.pens[index] = color
def to_bytes(self) -> bytes:
command: list[bytes] = [f"NP{self.max_pens-1};".encode()]
pens: list[tuple[int, colors.RGB]] = [
(index, rgb) for index, rgb in self.pens.items()
]
pens.sort()
for index, rgb in pens:
command.append(make_pc(index, rgb))
return b"".join(command)
def make_pc(pen: int, rgb: colors.RGB) -> bytes:
# pen color
return f"PC{pen},{rgb.r},{rgb.g},{rgb.b};".encode()
class _RenderBackend(BackendInterface):
"""Creates the HPGL/2 output.
This backend requires some preliminary work, record the frontend output via the
Recorder backend to accomplish the following requirements:
- Move content in the first quadrant of the coordinate system.
- The output coordinates are integer values, scale the content appropriately:
- 1 plot unit (plu) = 0.025mm
- 40 plu = 1mm
- 1016 plu = 1 inch
- 3.39 plu = 1 dot @300 dpi
- Replay the recorded output on this backend.
"""
def __init__(
self,
page: layout.Page,
*,
settings: layout.Settings,
curves=True,
decimal_places: int = 2,
base: int = 64,
) -> None:
self.settings = settings
self.curves = curves
self.factional_bits = round(decimal_places * 3.33)
self.decimal_places: int | None = (
int(decimal_places) if decimal_places else None
)
self.base = base
self.header: list[bytes] = []
self.data: list[bytes] = []
self.pen_table = PenTable(max_pens=256)
self.current_pen: int = 0
self.current_pen_width: float = 0.0
self.setup(page)
self._stroke_width_cache: dict[float, float] = dict()
# StrokeWidthPolicy.absolute:
# pen width in mm as resolved by the frontend
self.min_lineweight = 0.05 # in mm, set by configure()
self.lineweight_scaling = 1.0 # set by configure()
self.lineweight_policy = LineweightPolicy.ABSOLUTE # set by configure()
# fixed lineweight for all strokes in ABSOLUTE mode:
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
# set Configuration.lineweight_scaling to 0
# LineweightPolicy.RELATIVE:
# max_stroke_width is determined as a certain percentage of max(width, height)
max_size = max(page.width_in_mm, page.height_in_mm)
self.max_stroke_width: float = round(max_size * settings.max_stroke_width, 2)
# min_stroke_width is determined as a certain percentage of max_stroke_width
self.min_stroke_width: float = round(
self.max_stroke_width * settings.min_stroke_width, 2
)
# LineweightPolicy.RELATIVE_FIXED:
# all strokes have a fixed stroke-width as a certain percentage of max_stroke_width
self.fixed_stroke_width: float = round(
self.max_stroke_width * settings.fixed_stroke_width, 2
)
def setup(self, page: layout.Page) -> None:
cmd = f"PS{page.width_in_mm*MM_TO_PLU:.0f},{page.height_in_mm*MM_TO_PLU:.0f};"
self.header.append(cmd.encode())
self.header.append(b"FT1;PA;") # solid fill; plot absolute;
def get_bytes(self) -> bytes:
header: list[bytes] = list(self.header)
header.append(self.pen_table.to_bytes())
return compile_hpgl2(header, self.data)
def switch_current_pen(self, pen_number: int, rgb: colors.RGB) -> int:
if pen_number in self.pen_table:
pen_color = self.pen_table[pen_number]
if rgb != pen_color:
self.data.append(make_pc(DEFAULT_PEN, rgb))
pen_number = DEFAULT_PEN
else:
self.pen_table.add_pen(pen_number, rgb)
return pen_number
def set_pen(self, pen_number: int) -> None:
if self.current_pen == pen_number:
return
self.data.append(f"SP{pen_number};".encode())
self.current_pen = pen_number
def set_pen_width(self, width: float) -> None:
if self.current_pen_width == width:
return
self.data.append(f"PW{width:g};".encode()) # pen width in mm
self.current_pen_width = width
def enter_polygon_mode(self, start_point: Vec2) -> None:
x = round(start_point.x, self.decimal_places)
y = round(start_point.y, self.decimal_places)
self.data.append(f"PA;PU{x},{y};PM;".encode())
def close_current_polygon(self) -> None:
self.data.append(b"PM1;")
def fill_polygon(self) -> None:
self.data.append(b"PM2;FP;") # even/odd fill method
def set_properties(self, properties: BackendProperties) -> None:
pen_number = properties.pen
pen_color, opacity = self.resolve_pen_color(properties.color)
pen_width = self.resolve_pen_width(properties.lineweight)
pen_number = self.switch_current_pen(pen_number, pen_color)
self.set_pen(pen_number)
self.set_pen_width(pen_width)
def add_polyline_encoded(
self, vertices: Iterable[Vec2], properties: BackendProperties
):
self.set_properties(properties)
self.data.append(polyline_encoder(vertices, self.factional_bits, self.base))
def add_path(self, path: BkPath2d, properties: BackendProperties):
if self.curves and path.has_curves:
self.set_properties(properties)
self.data.append(path_encoder(path, self.decimal_places))
else:
points = list(path.flattening(MAX_FLATTEN, segments=4))
self.add_polyline_encoded(points, properties)
@staticmethod
def resolve_pen_color(color: Color) -> tuple[colors.RGB, float]:
rgb = colors.RGB.from_hex(color)
if rgb == WHITE:
rgb = BLACK
return rgb, alpha_to_opacity(color[7:9])
def resolve_pen_width(self, width: float) -> float:
try:
return self._stroke_width_cache[width]
except KeyError:
pass
stroke_width = self.fixed_stroke_width
policy = self.lineweight_policy
if policy == LineweightPolicy.ABSOLUTE:
if self.lineweight_scaling:
width = max(self.min_lineweight, width) * self.lineweight_scaling
else:
width = self.min_lineweight
stroke_width = round(width, 2) # in mm
elif policy == LineweightPolicy.RELATIVE:
stroke_width = round(
map_lineweight_to_stroke_width(
width, self.min_stroke_width, self.max_stroke_width
),
2,
)
self._stroke_width_cache[width] = stroke_width
return stroke_width
def set_background(self, color: Color) -> None:
# background is always a white paper
pass
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.add_polyline_encoded([pos], properties)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.add_polyline_encoded([start, end], properties)
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
lines = list(lines)
if len(lines) == 0:
return
for line in lines:
self.add_polyline_encoded(line, properties)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
for sub_path in path.sub_paths():
if len(sub_path) == 0:
continue
self.add_path(sub_path, properties)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
paths = list(paths)
if len(paths) == 0:
return
self.enter_polygon_mode(paths[0].start)
for p in paths:
for sub_path in p.sub_paths():
if len(sub_path) == 0:
continue
self.add_path(sub_path, properties)
self.close_current_polygon()
self.fill_polygon()
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
points2d: list[Vec2] = points.vertices()
if points2d:
self.enter_polygon_mode(points2d[0])
self.add_polyline_encoded(points2d, properties)
self.fill_polygon()
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
pass # TODO: not implemented
def configure(self, config: Configuration) -> None:
self.lineweight_policy = config.lineweight_policy
if config.min_lineweight:
# config.min_lineweight in 1/300 inch!
min_lineweight_mm = config.min_lineweight * 25.4 / 300
self.min_lineweight = max(0.05, min_lineweight_mm)
self.lineweight_scaling = config.lineweight_scaling
def clear(self) -> None:
pass
def finalize(self) -> None:
pass
def enter_entity(self, entity, properties) -> None:
pass
def exit_entity(self, entity) -> None:
pass
def alpha_to_opacity(alpha: str) -> float:
# stroke-opacity: 0.0 = transparent; 1.0 = opaque
# alpha: "00" = transparent; "ff" = opaque
if len(alpha):
try:
return int(alpha, 16) / 255
except ValueError:
pass
return 1.0
def map_lineweight_to_stroke_width(
lineweight: float,
min_stroke_width: float,
max_stroke_width: float,
min_lineweight=0.05, # defined by DXF
max_lineweight=2.11, # defined by DXF
) -> float:
lineweight = max(min(lineweight, max_lineweight), min_lineweight) - min_lineweight
factor = (max_stroke_width - min_stroke_width) / (max_lineweight - min_lineweight)
return round(min_stroke_width + round(lineweight * factor), 2)
def flatten_path(path: BkPath2d) -> Sequence[Vec2]:
points = list(path.flattening(distance=FLATTEN_MAX))
return points
def compile_hpgl2(header: Sequence[bytes], commands: Sequence[bytes]) -> bytes:
output = bytearray(PRELUDE)
output.extend(b"".join(header))
output.extend(b"".join(commands))
output.extend(EPILOG)
return bytes(output)
def pe_encode(value: float, frac_bits: int = 0, base: int = 64) -> bytes:
if frac_bits:
value *= 1 << frac_bits
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 polyline_encoder(vertices: Iterable[Vec2], frac_bits: int, base: int) -> bytes:
cmd = b"PE"
if base == 32:
cmd = b"PE7"
if frac_bits:
cmd += b">" + pe_encode(frac_bits, 0, base)
data = [cmd + b"<="]
vertices = list(vertices)
# first point as absolute coordinates
current = vertices[0]
data.append(pe_encode(current.x, frac_bits, base))
data.append(pe_encode(current.y, frac_bits, base))
for vertex in vertices[1:]:
# remaining points as relative coordinates
delta = vertex - current
data.append(pe_encode(delta.x, frac_bits, base))
data.append(pe_encode(delta.y, frac_bits, base))
current = vertex
data.append(b";")
return b"".join(data)
@no_type_check
def path_encoder(path: BkPath2d, decimal_places: int | None) -> bytes:
# first point as absolute coordinates
current = path.start
x = round(current.x, decimal_places)
y = round(current.y, decimal_places)
data = [f"PU;PA{x:g},{y:g};PD;".encode()]
prev_command = Command.MOVE_TO
if len(path):
commands: list[bytes] = []
for cmd in path.commands():
delta = cmd.end - current
xe = round(delta.x, decimal_places)
ye = round(delta.y, decimal_places)
if cmd.type == Command.LINE_TO:
coords = f"{xe:g},{ye:g};".encode()
if prev_command == Command.LINE_TO:
# extend previous PR command
commands[-1] = commands[-1][:-1] + b"," + coords
else:
commands.append(b"PR" + coords)
prev_command = Command.LINE_TO
else:
if cmd.type == Command.CURVE3_TO:
control = cmd.ctrl - current
end = cmd.end - current
control_1 = 2.0 * control / 3.0
control_2 = end + 2.0 * (control - end) / 3.0
elif cmd.type == Command.CURVE4_TO:
control_1 = cmd.ctrl1 - current
control_2 = cmd.ctrl2 - current
else:
raise ValueError("internal error: MOVE_TO command is illegal here")
x1 = round(control_1.x, decimal_places)
y1 = round(control_1.y, decimal_places)
x2 = round(control_2.x, decimal_places)
y2 = round(control_2.y, decimal_places)
coords = f"{x1:g},{y1:g},{x2:g},{y2:g},{xe:g},{ye:g};".encode()
if prev_command == Command.CURVE4_TO:
# extend previous BR command
commands[-1] = commands[-1][:-1] + b"," + coords
else:
commands.append(b"BR" + coords)
prev_command = Command.CURVE4_TO
current = cmd.end
data.append(b"".join(commands))
data.append(b"PU;")
return b"".join(data)
@@ -0,0 +1,590 @@
# Copyright (c) 2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Sequence, no_type_check, Any, Callable, Dict, List, Tuple
from typing_extensions import TypeAlias, override
import abc
import json
from ezdxf.math import Vec2, world_mercator_to_gps
from ezdxf.path import Command, nesting
from ezdxf.npshapes import orient_paths, single_paths
from .type_hints import Color
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import Configuration
from .properties import BackendProperties
__all__ = ["CustomJSONBackend", "GeoJSONBackend"]
CUSTOM_JSON_SPECS = """
JSON content = [entity, entity, ...]
entity = {
"type": point | lines | path | filled-paths | filled-polygon,
"properties": {
"color": "#RRGGBBAA",
"stroke-width": 0.25, # in mm
"layer": "name"
},
"geometry": depends on "type"
}
DXF linetypes (DASH, DOT, ...) are resolved into solid lines.
A single point:
point = {
"type": "point",
"properties": {...},
"geometry": [x, y]
}
Multiple lines with common properties:
lines = {
"type": "lines",
"properties": {...},
"geometry": [
(x0, y0, x1, y1), # 1. line
(x0, y0, x1, y1), # 2. line
....
]
}
Lines can contain points where x0 == x1 and y0 == y1!
A single linear path without filling:
path = {
"type": "path",
"properties": {...},
"geometry": [path-command, ...]
}
SVG-like path structure:
- The first path-command is always an absolute move to "M"
- The "M" command does not appear inside a path, each path is a continuouse geometry
(no multi-paths).
path-command =
("M", x, y) = absolute move to
("L", x, y) = absolute line to
("Q", x0, y0, x1, y1) = absolute quadratice Bezier curve to
- (x0, y0) = control point
- (x1, y1) = end point
("C", x0, y0, x1, y1, x2, y2) = absolute cubic Bezier curve to
- (x0, y0) = control point 1
- (x1, y1) = control point 2
- (x2, y2) = end point
("Z",) = close path
Multiple filled paths:
Exterior paths and holes are mixed and NOT oriented by default (clockwise or
counter-clockwise) - PyQt and SVG have no problem with that structure but matplotlib
requires oriented paths. When oriented paths are required the CustomJSONBackend can
orient the paths on demand.
filled-paths = {
"type": "filled-paths",
"properties": {...},
"geometry": [
[path-command, ...], # 1. path
[path-command, ...], # 2. path
...
]
}
A single filled polygon:
A polygon is explicitly closed, so first vertex == last vertex is guaranteed.
filled-polygon = {
"type": "filled-polygon",
"properties": {...},
"geometry": [
(x0, y0),
(x1, y1),
(x2, y2),
...
]
}
"""
class _JSONBackend(BackendInterface):
def __init__(self) -> None:
self._entities: list[dict[str, Any]] = []
self.max_sagitta = 0.01 # set by configure()
self.min_lineweight = 0.05 # in mm, set by configure()
self.lineweight_scaling = 1.0 # set by configure()
# set fixed lineweight for all strokes:
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
# set Configuration.lineweight_scaling to 0
self.fixed_lineweight = 0.0
@abc.abstractmethod
def get_json_data(self) -> Any: ...
def get_string(self, *, indent: int | str = 2) -> str:
"""Returns the result as a JSON string."""
return json.dumps(self.get_json_data(), indent=indent)
@override
def configure(self, config: Configuration) -> None:
if config.min_lineweight:
# config.min_lineweight in 1/300 inch!
min_lineweight_mm = config.min_lineweight * 25.4 / 300
self.min_lineweight = max(0.05, min_lineweight_mm)
self.lineweight_scaling = config.lineweight_scaling
if self.lineweight_scaling == 0.0:
# use a fixed lineweight for all strokes defined by min_lineweight
self.fixed_lineweight = self.min_lineweight
self.max_sagitta = config.max_flattening_distance
@override
def clear(self) -> None:
self._entities.clear()
@override
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
pass
@override
def set_background(self, color: Color) -> None:
pass
@override
def finalize(self) -> None:
pass
@override
def enter_entity(self, entity, properties) -> None:
pass
@override
def exit_entity(self, entity) -> None:
pass
MOVE_TO_ABS = "M"
LINE_TO_ABS = "L"
QUAD_TO_ABS = "Q"
CUBIC_TO_ABS = "C"
CLOSE_PATH = "Z"
class CustomJSONBackend(_JSONBackend):
"""Creates a JSON-like output with a custom JSON scheme. This scheme supports
curved shapes by a SVG-path like structure and coordinates are not limited in
any way. This backend can be used to send geometries from a web-backend to a
frontend.
The JSON scheme is documented in the source code:
https://github.com/mozman/ezdxf/blob/master/src/ezdxf/addons/drawing/json.py
Args:
orient_paths: orient exterior and hole paths on demand, exterior paths have
counter-clockwise orientation and holes have clockwise orientation.
**Class Methods**
.. automethod:: get_json_data
.. automethod:: get_string
.. versionadded:: 1.3.0
"""
def __init__(self, orient_paths=False) -> None:
super().__init__()
self.orient_paths = orient_paths
@override
def get_json_data(self) -> list[dict[str, Any]]:
"""Returns the result as a JSON-like data structure."""
return self._entities
def add_entity(
self, entity_type: str, geometry: Sequence[Any], properties: BackendProperties
):
if not geometry:
return
self._entities.append(
{
"type": entity_type,
"properties": self.make_properties_dict(properties),
"geometry": geometry,
}
)
def make_properties_dict(self, properties: BackendProperties) -> dict[str, Any]:
if self.fixed_lineweight:
stroke_width = self.fixed_lineweight
else:
stroke_width = max(
self.min_lineweight, properties.lineweight * self.lineweight_scaling
)
return {
"color": properties.color,
"stroke-width": round(stroke_width, 2),
"layer": properties.layer,
}
@override
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.add_entity("point", [pos.x, pos.y], properties)
@override
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.add_entity("lines", [(start.x, start.y, end.x, end.y)], properties)
@override
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
lines = list(lines)
if len(lines) == 0:
return
self.add_entity("lines", [(s.x, s.y, e.x, e.y) for s, e in lines], properties)
@override
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
self.add_entity("path", make_json_path(path), properties)
@override
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
paths = list(paths)
if len(paths) == 0:
return
if self.orient_paths:
paths = orient_paths(paths) # returns single paths
else:
# Just single paths allowed, no multi paths!
paths = single_paths(paths)
json_paths: list[Any] = []
for path in paths:
if len(path):
json_paths.append(make_json_path(path, close=True))
if json_paths:
self.add_entity("filled-paths", json_paths, properties)
@override
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
vertices: list[Vec2] = points.vertices()
if len(vertices) < 3:
return
if not vertices[0].isclose(vertices[-1]):
vertices.append(vertices[0])
self.add_entity("filled-polygon", [(v.x, v.y) for v in vertices], properties)
@no_type_check
def make_json_path(path: BkPath2d, close=False) -> list[Any]:
if len(path) == 0:
return []
end: Vec2 = path.start
commands: list = [(MOVE_TO_ABS, end.x, end.y)]
for cmd in path.commands():
end = cmd.end
if cmd.type == Command.MOVE_TO:
commands.append((MOVE_TO_ABS, end.x, end.y))
elif cmd.type == Command.LINE_TO:
commands.append((LINE_TO_ABS, end.x, end.y))
elif cmd.type == Command.CURVE3_TO:
c1 = cmd.ctrl
commands.append((QUAD_TO_ABS, c1.x, c1.y, end.x, end.y))
elif cmd.type == Command.CURVE4_TO:
c1 = cmd.ctrl1
c2 = cmd.ctrl2
commands.append((CUBIC_TO_ABS, c1.x, c1.y, c2.x, c2.y, end.x, end.y))
if close:
commands.append(CLOSE_PATH)
return commands
# dict and list not allowed here for Python < 3.10
PropertiesMaker: TypeAlias = Callable[[str, float, str], Dict[str, Any]]
TransformFunc: TypeAlias = Callable[[Vec2], Tuple[float, float]]
# GeoJSON ring
Ring: TypeAlias = List[Tuple[float, float]]
# The first ring is the exterior path followed by optional holes, nested polygons are
# not supported by the GeoJSON specification.
GeoJsonPolygon: TypeAlias = List[Ring]
def properties_maker(color: str, stroke_width: float, layer: str) -> dict[str, Any]:
"""Returns the property dict::
{
"color": color,
"stroke-width": stroke_width,
"layer": layer,
}
Returning an empty dict prevents properties in the GeoJSON output and also avoids
wraping entities into "Feature" objects.
"""
return {
"color": color,
"stroke-width": round(stroke_width, 2),
"layer": layer,
}
def no_transform(location: Vec2) -> tuple[float, float]:
"""Dummy transformation function. Does not apply any transformations and
just returns the input coordinates.
"""
return (location.x, location.y)
def make_world_mercator_to_gps_function(tol: float = 1e-6) -> TransformFunc:
"""Returns a function to transform WGS84 World Mercator `EPSG:3395 <https://epsg.io/3395>`_
location given as cartesian 2D coordinates x, y in meters into WGS84 decimal
degrees as longitude and latitude `EPSG:4326 <https://epsg.io/4326>`_ as
used by GPS.
Args:
tol: accuracy for latitude calculation
"""
def _transform(location: Vec2) -> tuple[float, float]:
"""Transforms WGS84 World Mercator EPSG:3395 coordinates to WGS84 EPSG:4326."""
return world_mercator_to_gps(location.x, location.y, tol)
return _transform
class GeoJSONBackend(_JSONBackend):
"""Creates a JSON-like output according the `GeoJSON`_ scheme.
GeoJSON uses a geographic coordinate reference system, World Geodetic
System 1984 `EPSG:4326 <https://epsg.io/4326>`_, and units of decimal degrees.
- Latitude: -90 to +90 (South/North)
- Longitude: -180 to +180 (East/West)
So most DXF files will produce invalid coordinates and it is the job of the
**package-user** to provide a function to transfrom the input coordinates to
EPSG:4326! The :class:`~ezdxf.addons.drawing.recorder.Recorder` and
:class:`~ezdxf.addons.drawing.recorder.Player` classes can help to detect the
extents of the DXF content.
Default implementation:
.. autofunction:: no_transform
Factory function to make a transform function from WGS84 World Mercator
`EPSG:3395 <https://epsg.io/3395>`_ coordinates to WGS84 (GPS)
`EPSG:4326 <https://epsg.io/4326>`_.
.. autofunction:: make_world_mercator_to_gps_function
The GeoJSON format supports only straight lines so curved shapes are flattened to
polylines and polygons.
The properties are handled as a foreign member feature and is therefore not defined
in the GeoJSON specs. It is possible to provide a custom function to create these
property objects.
Default implementation:
.. autofunction:: properties_maker
Args:
properties_maker: function to create a properties dict.
**Class Methods**
.. automethod:: get_json_data
.. automethod:: get_string
.. versionadded:: 1.3.0
.. _GeoJSON: https://geojson.org/
"""
def __init__(
self,
properties_maker: PropertiesMaker = properties_maker,
transform_func: TransformFunc = no_transform,
) -> None:
super().__init__()
self._properties_dict_maker = properties_maker
self._transform_function = transform_func
@override
def get_json_data(self) -> dict[str, Any]:
"""Returns the result as a JSON-like data structure according the GeoJSON specs."""
if len(self._entities) == 0:
return {}
using_features = self._entities[0]["type"] == "Feature"
if using_features:
return {"type": "FeatureCollection", "features": self._entities}
else:
return {"type": "GeometryCollection", "geometries": self._entities}
def add_entity(self, entity: dict[str, Any], properties: BackendProperties):
if not entity:
return
properties_dict: dict[str, Any] = self._properties_dict_maker(
*self.make_properties(properties)
)
if properties_dict:
self._entities.append(
{
"type": "Feature",
"properties": properties_dict,
"geometry": entity,
}
)
else:
self._entities.append(entity)
def make_properties(self, properties: BackendProperties) -> tuple[str, float, str]:
if self.fixed_lineweight:
stroke_width = self.fixed_lineweight
else:
stroke_width = max(
self.min_lineweight, properties.lineweight * self.lineweight_scaling
)
return (properties.color, round(stroke_width, 2), properties.layer)
@override
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.add_entity(
geojson_object("Point", list(self._transform_function(pos))), properties
)
@override
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
tf = self._transform_function
self.add_entity(
geojson_object("LineString", [tf(start), tf(end)]),
properties,
)
@override
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
lines = list(lines)
if len(lines) == 0:
return
tf = self._transform_function
json_lines = [(tf(s), tf(e)) for s, e in lines]
self.add_entity(geojson_object("MultiLineString", json_lines), properties)
@override
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
if len(path) == 0:
return
tf = self._transform_function
vertices = [tf(v) for v in path.flattening(distance=self.max_sagitta)]
self.add_entity(geojson_object("LineString", vertices), properties)
@override
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
paths = list(paths)
if len(paths) == 0:
return
polygons: list[GeoJsonPolygon] = []
for path in paths:
if len(path):
polygons.extend(
geojson_polygons(
path, max_sagitta=self.max_sagitta, tf=self._transform_function
)
)
if polygons:
self.add_entity(geojson_object("MultiPolygon", polygons), properties)
@override
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
vertices: list[Vec2] = points.vertices()
if len(vertices) < 3:
return
if not vertices[0].isclose(vertices[-1]):
vertices.append(vertices[0])
# exterior ring, without holes
tf = self._transform_function
self.add_entity(
geojson_object("Polygon", [[tf(v) for v in vertices]]), properties
)
def geojson_object(name: str, coordinates: Any) -> dict[str, Any]:
return {"type": name, "coordinates": coordinates}
def geojson_ring(
path: BkPath2d, is_hole: bool, max_sagitta: float, tf: TransformFunc
) -> Ring:
"""Returns a linear ring according to the GeoJSON specs.
- A linear ring is a closed LineString with four or more positions.
- The first and last positions are equivalent, and they MUST contain
identical values; their representation SHOULD also be identical.
- A linear ring is the boundary of a surface or the boundary of a
hole in a surface.
- A linear ring MUST follow the right-hand rule with respect to the
area it bounds, i.e., exterior rings are counterclockwise, and
holes are clockwise.
"""
if path.has_sub_paths:
raise TypeError("multi-paths not allowed")
vertices: Ring = [tf(v) for v in path.flattening(max_sagitta)]
if not path.is_closed:
start = path.start
vertices.append(tf(start))
clockwise = path.has_clockwise_orientation()
if (is_hole and not clockwise) or (not is_hole and clockwise):
vertices.reverse()
return vertices
def geojson_polygons(
path: BkPath2d, max_sagitta: float, tf: TransformFunc
) -> list[GeoJsonPolygon]:
"""Returns a list of polygons, where each polygon is a list of an exterior path and
optional holes e.g. [[ext0, hole0, hole1], [ext1], [ext2, hole0], ...].
"""
sub_paths: list[BkPath2d] = path.sub_paths()
if len(sub_paths) == 0:
return []
if len(sub_paths) == 1:
return [[geojson_ring(sub_paths[0], False, max_sagitta, tf)]]
polygons = nesting.make_polygon_structure(sub_paths)
geojson_polygons: list[GeoJsonPolygon] = []
for polygon in polygons:
geojson_polygon: GeoJsonPolygon = [
geojson_ring(polygon[0], False, max_sagitta, tf)
] # exterior ring
if len(polygon) > 1:
# GeoJSON has no support for nested hole structures, so the sub polygons of
# holes (hole[1]) are ignored yet!
holes = polygon[1]
if isinstance(holes, BkPath2d): # single hole
geojson_polygon.append(geojson_ring(holes, True, max_sagitta, tf))
continue
if isinstance(holes, (tuple, list)): # multiple holes
for hole in holes:
if isinstance(hole, (tuple, list)): # nested polygon
# TODO: add sub polygons of holes as separated polygons
hole = hole[0] # exterior path
geojson_polygon.append(geojson_ring(hole, True, max_sagitta, tf))
geojson_polygons.append(geojson_polygon)
return geojson_polygons
@@ -0,0 +1,561 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import NamedTuple, TYPE_CHECKING
from typing_extensions import Self
import math
import enum
import dataclasses
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
if TYPE_CHECKING:
from ezdxf.layouts.layout import Layout as DXFLayout
class Units(enum.IntEnum):
"""Page units as enum.
Attributes:
inch: 25.4 mm
px: 1/96 inch
pt: 1/72 inch
mm:
cm:
"""
# equivalent to ezdxf.units if possible
inch = 1
px = 2 # no equivalent DXF unit
pt = 3 # no equivalent DXF unit
mm = 4
cm = 5
# all page sizes in landscape orientation
PAGE_SIZES = {
"ISO A0": (1189, 841, Units.mm),
"ISO A1": (841, 594, Units.mm),
"ISO A2": (594, 420, Units.mm),
"ISO A3": (420, 297, Units.mm),
"ISO A4": (297, 210, Units.mm),
"ANSI A": (11, 8.5, Units.inch),
"ANSI B": (17, 11, Units.inch),
"ANSI C": (22, 17, Units.inch),
"ANSI D": (34, 22, Units.inch),
"ANSI E": (44, 34, Units.inch),
"ARCH C": (24, 18, Units.inch),
"ARCH D": (36, 24, Units.inch),
"ARCH E": (48, 36, Units.inch),
"ARCH E1": (42, 30, Units.inch),
"Letter": (11, 8.5, Units.inch),
"Legal": (14, 8.5, Units.inch),
}
UNITS_TO_MM = {
Units.mm: 1.0,
Units.cm: 10.0,
Units.inch: 25.4,
Units.px: 25.4 / 96.0,
Units.pt: 25.4 / 72.0,
}
class PageAlignment(enum.IntEnum):
"""Page alignment of content as enum.
Attributes:
TOP_LEFT:
TOP_CENTER:
TOP_RIGHT:
MIDDLE_LEFT:
MIDDLE_CENTER:
MIDDLE_RIGHT:
BOTTOM_LEFT:
BOTTOM_CENTER:
BOTTOM_RIGHT:
"""
TOP_LEFT = 1
TOP_CENTER = 2
TOP_RIGHT = 3
MIDDLE_LEFT = 4
MIDDLE_CENTER = 5
MIDDLE_RIGHT = 6
BOTTOM_LEFT = 7
BOTTOM_CENTER = 8
BOTTOM_RIGHT = 9
class Margins(NamedTuple):
"""Page margins definition class
Attributes:
top:
left:
bottom:
right:
"""
top: float
right: float
bottom: float
left: float
@classmethod
def all(cls, margin: float) -> Self:
"""Returns a page margins definition class with four equal margins."""
return cls(margin, margin, margin, margin)
@classmethod
def all2(cls, top_bottom: float, left_right: float) -> Self:
"""Returns a page margins definition class with equal top-bottom and
left-right margins.
"""
return cls(top_bottom, left_right, top_bottom, left_right)
# noinspection PyArgumentList
def scale(self, factor: float) -> Self:
return self.__class__(
self.top * factor,
self.right * factor,
self.bottom * factor,
self.left * factor,
)
@dataclasses.dataclass
class Page:
"""Page definition class
Attributes:
width: page width, 0 for auto-detect
height: page height, 0 for auto-detect
units: page units as enum :class:`Units`
margins: page margins in page units
max_width: limit width for auto-detection, 0 for unlimited
max_height: limit height for auto-detection, 0 for unlimited
"""
width: float
height: float
units: Units = Units.mm
margins: Margins = Margins.all(0)
max_width: float = 0.0
max_height: float = 0.0
def __post_init__(self):
assert isinstance(self.units, Units), "units require type <Units>"
assert isinstance(self.margins, Margins), "margins require type <Margins>"
@property
def to_mm_factor(self) -> float:
return UNITS_TO_MM[self.units]
@property
def width_in_mm(self) -> float:
"""Returns the page width in mm."""
return round(self.width * self.to_mm_factor, 1)
@property
def max_width_in_mm(self) -> float:
"""Returns max page width in mm."""
return round(self.max_width * self.to_mm_factor, 1)
@property
def height_in_mm(self) -> float:
"""Returns the page height in mm."""
return round(self.height * self.to_mm_factor, 1)
@property
def max_height_in_mm(self) -> float:
"""Returns max page height in mm."""
return round(self.max_height * self.to_mm_factor, 1)
@property
def margins_in_mm(self) -> Margins:
"""Returns the page margins in mm."""
return self.margins.scale(self.to_mm_factor)
@property
def is_landscape(self) -> bool:
"""Returns ``True`` if the page has landscape orientation."""
return self.width > self.height
@property
def is_portrait(self) -> bool:
"""Returns ``True`` if the page has portrait orientation. (square is portrait)"""
return self.width <= self.height
def to_landscape(self) -> None:
"""Converts the page to landscape orientation."""
if self.is_portrait:
self.width, self.height = self.height, self.width
def to_portrait(self) -> None:
"""Converts the page to portrait orientation."""
if self.is_landscape:
self.width, self.height = self.height, self.width
def get_margin_rect(self, top_origin=True) -> tuple[Vec2, Vec2]:
"""Returns the bottom-left and the top-right corner of the page margins in mm.
The origin (0, 0) is the top-left corner of the page if `top_origin` is
``True`` or in the bottom-left corner otherwise.
"""
margins = self.margins_in_mm
right_margin = self.width_in_mm - margins.right
page_height = self.height_in_mm
if top_origin:
bottom_left = Vec2(margins.left, margins.top)
top_right = Vec2(right_margin, page_height - margins.bottom)
else: # bottom origin
bottom_left = Vec2(margins.left, margins.bottom)
top_right = Vec2(right_margin, page_height - margins.top)
return bottom_left, top_right
@classmethod
def from_dxf_layout(cls, layout: DXFLayout) -> Self:
"""Returns the :class:`Page` based on the DXF attributes stored in the LAYOUT
entity. The modelspace layout often **doesn't** have usable page settings!
Args:
layout: any paperspace layout or the modelspace layout
"""
# all layout measurements in mm
width = round(layout.dxf.paper_width, 1)
height = round(layout.dxf.paper_height, 1)
top = round(layout.dxf.top_margin, 1)
right = round(layout.dxf.right_margin, 1)
bottom = round(layout.dxf.bottom_margin, 1)
left = round(layout.dxf.left_margin, 1)
rotation = layout.dxf.plot_rotation
if rotation == 1: # 90 degrees
return cls(
height,
width,
Units.mm,
margins=Margins(top=right, right=bottom, bottom=left, left=top),
)
elif rotation == 2: # 180 degrees
return cls(
width,
height,
Units.mm,
margins=Margins(top=bottom, right=left, bottom=top, left=right),
)
elif rotation == 3: # 270 degrees
return cls(
height,
width,
Units.mm,
margins=Margins(top=left, right=top, bottom=right, left=bottom),
)
return cls( # 0 degrees
width,
height,
Units.mm,
margins=Margins(top=top, right=right, bottom=bottom, left=left),
)
@dataclasses.dataclass
class Settings:
"""The Layout settings.
Attributes:
content_rotation: Rotate content about 0, 90, 180 or 270 degrees
fit_page: Scale content to fit the page.
page_alignment: Supported by backends that use the :class:`Page` class to define
the size of the output media, default alignment is :attr:`PageAlignment.MIDDLE_CENTER`
crop_at_margins: crops the content at the page margins if ``True``, when
supported by the backend, default is ``False``
scale: Factor to scale the DXF units of model- or paperspace, to represent 1mm
in the rendered output drawing. Only uniform scaling is supported.
e.g. scale 1:100 and DXF units are meters, 1m = 1000mm corresponds 10mm in
the output drawing = 10 / 1000 = 0.01;
e.g. scale 1:1; DXF units are mm = 1 / 1 = 1.0 the default value
The value is ignored if the page size is defined and the content fits the page and
the value is also used to determine missing page sizes (width or height).
max_stroke_width: Used for :class:`LineweightPolicy.RELATIVE` policy,
:attr:`max_stroke_width` is defined as percentage of the content extents,
e.g. 0.001 is 0.1% of max(page-width, page-height)
min_stroke_width: Used for :class:`LineweightPolicy.RELATIVE` policy,
:attr:`min_stroke_width` is defined as percentage of :attr:`max_stroke_width`,
e.g. 0.05 is 5% of :attr:`max_stroke_width`
fixed_stroke_width: Used for :class:`LineweightPolicy.RELATIVE_FIXED` policy,
:attr:`fixed_stroke_width` is defined as percentage of :attr:`max_stroke_width`,
e.g. 0.15 is 15% of :attr:`max_stroke_width`
output_coordinate_space: expert feature to map the DXF coordinates to the
output coordinate system [0, output_coordinate_space]
output_layers: For supported backends, separate the entities into 'layers' in the output
"""
content_rotation: int = 0
fit_page: bool = True
scale: float = 1.0
page_alignment: PageAlignment = PageAlignment.MIDDLE_CENTER
crop_at_margins: bool = False
# for LineweightPolicy.RELATIVE
# max_stroke_width is defined as percentage of the content extents
max_stroke_width: float = 0.001 # 0.1% of max(width, height) in viewBox coords
# min_stroke_width is defined as percentage of max_stroke_width
min_stroke_width: float = 0.05 # 5% of max_stroke_width
# StrokeWidthPolicy.fixed_1
# fixed_stroke_width is defined as percentage of max_stroke_width
fixed_stroke_width: float = 0.15 # 15% of max_stroke_width
# PDF, HPGL expect the coordinates in the first quadrant and SVG has an inverted
# y-axis, so transformation from DXF to the output coordinate system is required.
# The output_coordinate_space defines the space into which the DXF coordinates are
# mapped, the range is [0, output_coordinate_space] for the larger page
# dimension - aspect ratio is always preserved - these are CAD drawings!
# The SVGBackend uses this feature to map all coordinates to integer values:
output_coordinate_space: float = 1_000_000 # e.g. for SVGBackend
output_layers: bool = True
def __post_init__(self) -> None:
if self.content_rotation not in (0, 90, 180, 270):
raise ValueError(
f"invalid content rotation {self.content_rotation}, "
f"expected: 0, 90, 180, 270"
)
def page_output_scale_factor(self, page: Page) -> float:
"""Returns the scaling factor to map page coordinates in mm to output space
coordinates.
"""
try:
return self.output_coordinate_space / max(
page.width_in_mm, page.height_in_mm
)
except ZeroDivisionError:
return 1.0
class Layout:
def __init__(self, render_box: BoundingBox2d, flip_y=False) -> None:
super().__init__()
self.flip_y: float = -1.0 if flip_y else 1.0
self.render_box = render_box
def get_rotation(self, settings: Settings) -> int:
if settings.content_rotation not in (0, 90, 180, 270):
raise ValueError("content rotation must be 0, 90, 180 or 270 degrees")
rotation = settings.content_rotation
if self.flip_y == -1.0:
if rotation == 90:
rotation = 270
elif rotation == 270:
rotation = 90
return rotation
def get_content_size(self, rotation: int) -> Vec2:
content_size = self.render_box.size
if rotation in (90, 270):
# swap x, y to apply rotation to content_size
content_size = Vec2(content_size.y, content_size.x)
return content_size
def get_final_page(self, page: Page, settings: Settings) -> Page:
rotation = self.get_rotation(settings)
content_size = self.get_content_size(rotation)
return final_page_size(content_size, page, settings)
def get_placement_matrix(
self, page: Page, settings=Settings(), top_origin=True
) -> Matrix44:
# Argument `page` has to be the resolved final page size!
rotation = self.get_rotation(settings)
content_size = self.get_content_size(rotation)
content_size_mm = content_size * settings.scale
if settings.fit_page:
content_size_mm *= fit_to_page(content_size_mm, page)
try:
scale_dxf_to_mm = content_size_mm.x / content_size.x
except ZeroDivisionError:
scale_dxf_to_mm = 1.0
# map output coordinates to range [0, output_coordinate_space]
scale_mm_to_output_space = settings.page_output_scale_factor(page)
scale = scale_dxf_to_mm * scale_mm_to_output_space
m = placement_matrix(
self.render_box,
sx=scale,
sy=scale * self.flip_y,
rotation=rotation,
page=page,
output_coordinate_space=settings.output_coordinate_space,
page_alignment=settings.page_alignment,
top_origin=top_origin,
)
return m
def final_page_size(content_size: Vec2, page: Page, settings: Settings) -> Page:
scale = settings.scale
width = page.width_in_mm
height = page.height_in_mm
margins = page.margins_in_mm
if width == 0.0:
width = scale * content_size.x + margins.left + margins.right
if height == 0.0:
height = scale * content_size.y + margins.top + margins.bottom
width, height = limit_page_size(
width, height, page.max_width_in_mm, page.max_height_in_mm
)
return Page(round(width, 1), round(height, 1), Units.mm, margins)
def limit_page_size(
width: float, height: float, max_width: float, max_height: float
) -> tuple[float, float]:
try:
ar = width / height
except ZeroDivisionError:
return width, height
if max_height:
height = min(max_height, height)
width = height * ar
if max_width and width > max_width:
width = min(max_width, width)
height = width / ar
return width, height
def fit_to_page(content_size_mm: Vec2, page: Page) -> float:
margins = page.margins_in_mm
try:
sx = (page.width_in_mm - margins.left - margins.right) / content_size_mm.x
sy = (page.height_in_mm - margins.top - margins.bottom) / content_size_mm.y
except ZeroDivisionError:
return 1.0
return min(sx, sy)
def placement_matrix(
bbox: BoundingBox2d,
sx: float,
sy: float,
rotation: float,
page: Page,
output_coordinate_space: float,
page_alignment: PageAlignment = PageAlignment.MIDDLE_CENTER,
# top_origin True: page origin (0, 0) in top-left corner, +y axis pointing down
# top_origin False: page origin (0, 0) in bottom-left corner, +y axis pointing up
top_origin=True,
) -> Matrix44:
"""Returns a matrix to place the bbox in the first quadrant of the coordinate
system (+x, +y).
"""
try:
scale_mm_to_vb = output_coordinate_space / max(
page.width_in_mm, page.height_in_mm
)
except ZeroDivisionError:
scale_mm_to_vb = 1.0
margins = page.margins_in_mm
# create scaling and rotation matrix:
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))
# calc bounding box of the final output canvas:
corners = m.transform_vertices(bbox.rect_vertices())
canvas = BoundingBox2d(corners)
# shift content to first quadrant +x/+y
tx, ty = canvas.extmin
# align content within margins
view_box_content_x = (
page.width_in_mm - margins.left - margins.right
) * scale_mm_to_vb
view_box_content_y = (
page.height_in_mm - margins.top - margins.bottom
) * scale_mm_to_vb
dx = view_box_content_x - canvas.size.x
dy = view_box_content_y - canvas.size.y
offset_x = margins.left * scale_mm_to_vb # left
if top_origin:
offset_y = margins.top * scale_mm_to_vb
else:
offset_y = margins.bottom * scale_mm_to_vb
if is_center_aligned(page_alignment):
offset_x += dx / 2
elif is_right_aligned(page_alignment):
offset_x += dx
if is_middle_aligned(page_alignment):
offset_y += dy / 2
elif is_bottom_aligned(page_alignment):
if top_origin:
offset_y += dy
else: # top aligned
if not top_origin:
offset_y += dy
return m @ Matrix44.translate(-tx + offset_x, -ty + offset_y, 0)
def is_left_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.TOP_LEFT,
PageAlignment.MIDDLE_LEFT,
PageAlignment.BOTTOM_LEFT,
)
def is_center_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.TOP_CENTER,
PageAlignment.MIDDLE_CENTER,
PageAlignment.BOTTOM_CENTER,
)
def is_right_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.TOP_RIGHT,
PageAlignment.MIDDLE_RIGHT,
PageAlignment.BOTTOM_RIGHT,
)
def is_top_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.TOP_LEFT,
PageAlignment.TOP_CENTER,
PageAlignment.TOP_RIGHT,
)
def is_middle_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.MIDDLE_LEFT,
PageAlignment.MIDDLE_CENTER,
PageAlignment.MIDDLE_RIGHT,
)
def is_bottom_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.BOTTOM_LEFT,
PageAlignment.BOTTOM_CENTER,
PageAlignment.BOTTOM_RIGHT,
)
@@ -0,0 +1,361 @@
# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
from __future__ import annotations
from typing import Iterable, Optional, Union
import math
import logging
from os import PathLike
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.collections import LineCollection
from matplotlib.image import AxesImage
from matplotlib.lines import Line2D
from matplotlib.patches import PathPatch
from matplotlib.path import Path
from matplotlib.transforms import Affine2D
from ezdxf.npshapes import to_matplotlib_path
from ezdxf.addons.drawing.backend import Backend, BkPath2d, BkPoints2d, ImageData
from ezdxf.addons.drawing.properties import BackendProperties, LayoutProperties
from ezdxf.addons.drawing.type_hints import FilterFunc
from ezdxf.addons.drawing.type_hints import Color
from ezdxf.math import Vec2, Matrix44
from ezdxf.layouts import Layout
from .config import Configuration
logger = logging.getLogger("ezdxf")
# matplotlib docs: https://matplotlib.org/index.html
# line style:
# https://matplotlib.org/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D.set_linestyle
# https://matplotlib.org/gallery/lines_bars_and_markers/linestyles.html
# line width:
# https://matplotlib.org/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D.set_linewidth
# points unit (pt), 1pt = 1/72 inch, 1pt = 0.3527mm
POINTS = 1.0 / 0.3527 # mm -> points
CURVE4x3 = (Path.CURVE4, Path.CURVE4, Path.CURVE4)
SCATTER_POINT_SIZE = 0.1
def setup_axes(ax: plt.Axes):
# like set_axis_off, except that the face_color can still be set
ax.xaxis.set_visible(False)
ax.yaxis.set_visible(False)
for s in ax.spines.values():
s.set_visible(False)
ax.autoscale(False)
ax.set_aspect("equal", "datalim")
class MatplotlibBackend(Backend):
"""Backend which uses the :mod:`Matplotlib` package for image export.
Args:
ax: drawing canvas as :class:`matplotlib.pyplot.Axes` object
adjust_figure: automatically adjust the size of the parent
:class:`matplotlib.pyplot.Figure` to display all content
"""
def __init__(
self,
ax: plt.Axes,
*,
adjust_figure: bool = True,
):
super().__init__()
setup_axes(ax)
self.ax = ax
self._adjust_figure = adjust_figure
self._current_z = 0
def configure(self, config: Configuration) -> None:
if config.min_lineweight is None:
# If not set by user, use ~1 pixel
figure = self.ax.get_figure()
if figure:
config = config.with_changes(min_lineweight=72.0 / figure.dpi)
super().configure(config)
# LinePolicy.ACCURATE is handled by the frontend since v0.18.1
def _get_z(self) -> int:
z = self._current_z
self._current_z += 1
return z
def set_background(self, color: Color):
self.ax.set_facecolor(color)
def draw_point(self, pos: Vec2, properties: BackendProperties):
"""Draw a real dimensionless point."""
color = properties.color
self.ax.scatter(
[pos.x],
[pos.y],
s=SCATTER_POINT_SIZE,
c=color,
zorder=self._get_z(),
)
def get_lineweight(self, properties: BackendProperties) -> float:
"""Set lineweight_scaling=0 to use a constant minimal lineweight."""
assert self.config.min_lineweight is not None
return max(
properties.lineweight * self.config.lineweight_scaling,
self.config.min_lineweight,
)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties):
"""Draws a single solid line, line type rendering is done by the
frontend since v0.18.1
"""
if start.isclose(end):
# matplotlib draws nothing for a zero-length line:
self.draw_point(start, properties)
else:
self.ax.add_line(
Line2D(
(start.x, end.x),
(start.y, end.y),
linewidth=self.get_lineweight(properties),
color=properties.color,
zorder=self._get_z(),
)
)
def draw_solid_lines(
self,
lines: Iterable[tuple[Vec2, Vec2]],
properties: BackendProperties,
):
"""Fast method to draw a bunch of solid lines with the same properties."""
color = properties.color
lineweight = self.get_lineweight(properties)
_lines = []
point_x = []
point_y = []
z = self._get_z()
for s, e in lines:
if s.isclose(e):
point_x.append(s.x)
point_y.append(s.y)
else:
_lines.append(((s.x, s.y), (e.x, e.y)))
self.ax.scatter(point_x, point_y, s=SCATTER_POINT_SIZE, c=color, zorder=z)
self.ax.add_collection(
LineCollection(
_lines,
linewidths=lineweight,
color=color,
zorder=z,
capstyle="butt",
)
)
def draw_path(self, path: BkPath2d, properties: BackendProperties):
"""Draw a solid line path, line type rendering is done by the
frontend since v0.18.1
"""
mpl_path = to_matplotlib_path([path])
try:
patch = PathPatch(
mpl_path,
linewidth=self.get_lineweight(properties),
fill=False,
color=properties.color,
zorder=self._get_z(),
)
except ValueError as e:
logger.info(f"ignored matplotlib error: {str(e)}")
else:
self.ax.add_patch(patch)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
):
linewidth = 0
try:
patch = PathPatch(
to_matplotlib_path(paths, detect_holes=True),
color=properties.color,
linewidth=linewidth,
fill=True,
zorder=self._get_z(),
)
except ValueError as e:
logger.info(f"ignored matplotlib error in draw_filled_paths(): {str(e)}")
else:
self.ax.add_patch(patch)
def draw_filled_polygon(self, points: BkPoints2d, properties: BackendProperties):
self.ax.fill(
*zip(*((p.x, p.y) for p in points.vertices())),
color=properties.color,
linewidth=0,
zorder=self._get_z(),
)
def draw_image(
self, image_data: ImageData, properties: BackendProperties
) -> None:
height, width, depth = image_data.image.shape
assert depth == 4
# using AxesImage directly avoids an issue with ax.imshow where the data limits
# are updated to include the un-transformed image because the transform is applied
# afterward. We can use a slight hack which is that the outlines of images are drawn
# as well as the image itself, so we don't have to adjust the data limits at all here
# as the outline will take care of that
handle = AxesImage(self.ax, interpolation="antialiased")
handle.set_data(np.flip(image_data.image, axis=0))
handle.set_zorder(self._get_z())
(
m11,
m12,
m13,
m14,
m21,
m22,
m23,
m24,
m31,
m32,
m33,
m34,
m41,
m42,
m43,
m44,
) = image_data.transform
matplotlib_transform = Affine2D(
matrix=np.array(
[
[m11, m21, m41],
[m12, m22, m42],
[0, 0, 1],
]
)
)
handle.set_transform(matplotlib_transform + self.ax.transData)
self.ax.add_image(handle)
def clear(self):
self.ax.clear()
def finalize(self):
super().finalize()
self.ax.autoscale(True)
if self._adjust_figure:
minx, maxx = self.ax.get_xlim()
miny, maxy = self.ax.get_ylim()
data_width, data_height = maxx - minx, maxy - miny
if not math.isclose(data_width, 0):
width, height = plt.figaspect(data_height / data_width)
self.ax.get_figure().set_size_inches(width, height, forward=True)
def _get_aspect_ratio(ax: plt.Axes) -> float:
minx, maxx = ax.get_xlim()
miny, maxy = ax.get_ylim()
data_width, data_height = maxx - minx, maxy - miny
if abs(data_height) > 1e-9:
return data_width / data_height
return 1.0
def _get_width_height(ratio: float, width: float, height: float) -> tuple[float, float]:
if width == 0.0 and height == 0.0:
raise ValueError("invalid (width, height) values")
if width == 0.0:
width = height * ratio
elif height == 0.0:
height = width / ratio
return width, height
def qsave(
layout: Layout,
filename: Union[str, PathLike],
*,
bg: Optional[Color] = None,
fg: Optional[Color] = None,
dpi: int = 300,
backend: str = "agg",
config: Optional[Configuration] = None,
filter_func: Optional[FilterFunc] = None,
size_inches: Optional[tuple[float, float]] = None,
) -> None:
"""Quick and simplified render export by matplotlib.
Args:
layout: modelspace or paperspace layout to export
filename: export filename, file extension determines the format e.g.
"image.png" to save in PNG format.
bg: override default background color in hex format #RRGGBB or #RRGGBBAA,
e.g. use bg="#FFFFFF00" to get a transparent background and a black
foreground color (ACI=7), because a white background #FFFFFF gets a
black foreground color or vice versa bg="#00000000" for a transparent
(black) background and a white foreground color.
fg: override default foreground color in hex format #RRGGBB or #RRGGBBAA,
requires also `bg` argument. There is no explicit foreground color
in DXF defined (also not a background color), but the ACI color 7
has already a variable color value, black on a light background and
white on a dark background, this argument overrides this (ACI=7)
default color value.
dpi: image resolution (dots per inches).
size_inches: paper size in inch as `(width, height)` tuple, which also
defines the size in pixels = (`width` * `dpi`) x (`height` * `dpi`).
If `width` or `height` is 0.0 the value is calculated by the aspect
ratio of the drawing.
backend: the matplotlib rendering backend to use (agg, cairo, svg etc)
(see documentation for `matplotlib.use() <https://matplotlib.org/3.1.1/api/matplotlib_configuration_api.html?highlight=matplotlib%20use#matplotlib.use>`_
for a complete list of backends)
config: drawing parameters
filter_func: filter function which takes a DXFGraphic object as input
and returns ``True`` if the entity should be drawn or ``False`` if
the entity should be ignored
"""
from .properties import RenderContext
from .frontend import Frontend
import matplotlib
# Set the backend to prevent warnings about GUIs being opened from a thread
# other than the main thread.
old_backend = matplotlib.get_backend()
matplotlib.use(backend)
if config is None:
config = Configuration()
try:
fig: plt.Figure = plt.figure(dpi=dpi)
ax: plt.Axes = fig.add_axes((0, 0, 1, 1))
ctx = RenderContext(layout.doc)
layout_properties = LayoutProperties.from_layout(layout)
if bg is not None:
layout_properties.set_colors(bg, fg)
out = MatplotlibBackend(ax)
Frontend(ctx, out, config).draw_layout(
layout,
finalize=True,
filter_func=filter_func,
layout_properties=layout_properties,
)
# transparent=True sets the axes color to fully transparent
# facecolor sets the figure color
# (semi-)transparent axes colors do not produce transparent outputs
# but (semi-)transparent figure colors do.
if size_inches is not None:
ratio = _get_aspect_ratio(ax)
w, h = _get_width_height(ratio, size_inches[0], size_inches[1])
fig.set_size_inches(w, h, True)
fig.savefig(filename, dpi=dpi, facecolor=ax.get_facecolor(), transparent=True)
plt.close(fig)
finally:
matplotlib.use(old_backend)
@@ -0,0 +1,310 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Optional
from typing_extensions import Protocol
import copy
import math
from ezdxf import colors
from ezdxf.entities import MText
from ezdxf.lldxf import const
from ezdxf.math import Matrix44, Vec3, AnyVec
from ezdxf.render.abstract_mtext_renderer import AbstractMTextRenderer
from ezdxf.fonts import fonts
from ezdxf.tools import text_layout as tl
from ezdxf.tools.text import MTextContext
from .properties import Properties, RenderContext, rgb_to_hex
from .type_hints import Color
__all__ = ["complex_mtext_renderer"]
def corner_vertices(
left: float,
bottom: float,
right: float,
top: float,
m: Matrix44 = None,
) -> Iterable[Vec3]:
corners = [ # closed polygon: fist vertex == last vertex
(left, top),
(right, top),
(right, bottom),
(left, bottom),
(left, top),
]
if m is None:
return Vec3.generate(corners)
else:
return m.transform_vertices(corners)
class DrawInterface(Protocol):
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties) -> None:
...
def draw_filled_polygon(
self, points: Iterable[AnyVec], properties: Properties
) -> None:
...
def draw_text(
self,
text: str,
transform: Matrix44,
properties: Properties,
cap_height: float,
) -> None:
...
class FrameRenderer(tl.ContentRenderer):
def __init__(self, properties: Properties, backend: DrawInterface):
self.properties = properties
self.backend = backend
def render(
self,
left: float,
bottom: float,
right: float,
top: float,
m: Matrix44 = None,
) -> None:
self._render_outline(list(corner_vertices(left, bottom, right, top, m)))
def _render_outline(self, vertices: list[Vec3]) -> None:
backend = self.backend
properties = self.properties
prev = vertices.pop(0)
for vertex in vertices:
backend.draw_line(prev, vertex, properties)
prev = vertex
def line(
self, x1: float, y1: float, x2: float, y2: float, m: Matrix44 = None
) -> None:
points = [(x1, y1), (x2, y2)]
if m is not None:
p1, p2 = m.transform_vertices(points)
else:
p1, p2 = Vec3.generate(points)
self.backend.draw_line(p1, p2, self.properties)
class ColumnBackgroundRenderer(FrameRenderer):
def __init__(
self,
properties: Properties,
backend: DrawInterface,
bg_properties: Optional[Properties] = None,
offset: float = 0,
text_frame: bool = False,
):
super().__init__(properties, backend)
self.bg_properties = bg_properties
self.offset = offset # background border offset
self.has_text_frame = text_frame
def render(
self,
left: float,
bottom: float,
right: float,
top: float,
m: Matrix44 = None,
) -> None:
# Important: this is not a clipping box, it is possible to
# render anything outside of the given borders!
offset = self.offset
vertices = list(
corner_vertices(
left - offset, bottom - offset, right + offset, top + offset, m
)
)
if self.bg_properties is not None:
self.backend.draw_filled_polygon(vertices, self.bg_properties)
if self.has_text_frame:
self._render_outline(vertices)
class TextRenderer(FrameRenderer):
"""Text content renderer."""
def __init__(
self,
text: str,
cap_height: float,
width_factor: float,
oblique: float, # angle in degrees
properties: Properties,
backend: DrawInterface,
):
super().__init__(properties, backend)
self.text = text
self.cap_height = cap_height
self.width_factor = width_factor
self.oblique = oblique # angle in degrees
def render(
self,
left: float,
bottom: float,
right: float,
top: float,
m: Matrix44 = None,
):
"""Create/render the text content"""
sx = 1.0
tx = 0.0
if not math.isclose(self.width_factor, 1.0, rel_tol=1e-6):
sx = self.width_factor
if abs(self.oblique) > 1e-3: # degrees
tx = math.tan(math.radians(self.oblique))
# fmt: off
t = Matrix44((
sx, 0.0, 0.0, 0.0,
tx, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
left, bottom, 0.0, 1.0
))
# fmt: on
if m is not None:
t *= m
self.backend.draw_text(self.text, t, self.properties, self.cap_height)
def complex_mtext_renderer(
ctx: RenderContext,
backend: DrawInterface,
mtext: MText,
properties: Properties,
) -> None:
cmr = ComplexMTextRenderer(ctx, backend, properties)
align = tl.LayoutAlignment(mtext.dxf.attachment_point)
layout_engine = cmr.layout_engine(mtext)
layout_engine.place(align=align)
layout_engine.render(mtext.ucs().matrix)
class ComplexMTextRenderer(AbstractMTextRenderer):
def __init__(
self,
ctx: RenderContext,
backend: DrawInterface,
properties: Properties,
):
super().__init__()
self._render_ctx = ctx
self._backend = backend
self._properties = properties
# Implementation of required AbstractMTextRenderer methods:
def word(self, text: str, ctx: MTextContext) -> tl.ContentCell:
return tl.Text(
width=self.get_font(ctx).text_width(text),
height=ctx.cap_height,
valign=tl.CellAlignment(ctx.align),
stroke=self.get_stroke(ctx),
renderer=TextRenderer(
text,
ctx.cap_height,
ctx.width_factor,
ctx.oblique,
self.new_text_properties(self._properties, ctx),
self._backend,
),
)
def fraction(self, data: tuple[str, str, str], ctx: MTextContext) -> tl.ContentCell:
upr, lwr, type_ = data
if type_:
return tl.Fraction(
top=self.word(upr, ctx),
bottom=self.word(lwr, ctx),
stacking=self.get_stacking(type_),
# renders just the divider line:
renderer=FrameRenderer(self._properties, self._backend),
)
else:
return self.word(upr, ctx)
def get_font_face(self, mtext: MText) -> fonts.FontFace:
return self._properties.font # type: ignore
def make_bg_renderer(self, mtext: MText) -> tl.ContentRenderer:
dxf = mtext.dxf
bg_fill = dxf.get("bg_fill", 0)
bg_aci = None
bg_true_color = None
bg_properties: Optional[Properties] = None
has_text_frame = False
offset = 0
if bg_fill:
# The fill scale is a multiple of the initial char height and
# a scale of 1, fits exact the outer border
# of the column -> offset = 0
offset = dxf.char_height * (dxf.get("box_fill_scale", 1.5) - 1)
if bg_fill & const.MTEXT_BG_COLOR:
if dxf.hasattr("bg_fill_color"):
bg_aci = dxf.bg_fill_color
if dxf.hasattr("bg_fill_true_color"):
bg_aci = None
bg_true_color = dxf.bg_fill_true_color
if (bg_fill & 3) == 3: # canvas color = bit 0 and 1 set
# can not detect canvas color from DXF document!
# do not draw any background:
bg_aci = None
bg_true_color = None
if bg_fill & const.MTEXT_TEXT_FRAME:
has_text_frame = True
bg_properties = self.new_bg_properties(bg_aci, bg_true_color)
return ColumnBackgroundRenderer(
self._properties,
self._backend,
bg_properties,
offset=offset,
text_frame=has_text_frame,
)
# Implementation details of ComplexMTextRenderer:
@property
def backend(self) -> DrawInterface:
return self._backend
def resolve_aci_color(self, aci: int) -> Color:
return self._render_ctx.resolve_aci_color(aci, self._properties.layer)
def new_text_properties(
self, properties: Properties, ctx: MTextContext
) -> Properties:
new_properties = copy.copy(properties)
if ctx.rgb is None:
new_properties.color = self.resolve_aci_color(ctx.aci)
else:
new_properties.color = rgb_to_hex(ctx.rgb)
new_properties.font = ctx.font_face
return new_properties
def new_bg_properties(
self, aci: Optional[int], true_color: Optional[int]
) -> Properties:
new_properties = copy.copy(self._properties)
new_properties.color = ( # canvas background color
self._render_ctx.current_layout_properties.background_color
)
if true_color is None:
if aci is not None:
new_properties.color = self.resolve_aci_color(aci)
# else canvas background color
else:
new_properties.color = rgb_to_hex(colors.int2rgb(true_color))
return new_properties
@@ -0,0 +1,886 @@
# Copyright (c) 2023-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Sequence,
Optional,
Iterable,
Tuple,
Iterator,
Callable,
)
from typing_extensions import TypeAlias
import abc
import numpy as np
import PIL.Image
import PIL.ImageDraw
import PIL.ImageOps
from ezdxf.colors import RGB
import ezdxf.bbox
from ezdxf.fonts import fonts
from ezdxf.math import Vec2, Matrix44, BoundingBox2d, AnyVec
from ezdxf.path import make_path, Path
from ezdxf.render import linetypes
from ezdxf.entities import DXFGraphic, Viewport
from ezdxf.tools.text import replace_non_printable_characters
from ezdxf.tools.clipping_portal import (
ClippingPortal,
ClippingShape,
find_best_clipping_shape,
)
from ezdxf.layouts import Layout
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import LinePolicy, TextPolicy, ColorPolicy, Configuration
from .properties import BackendProperties, Filling
from .properties import Properties, RenderContext
from .type_hints import Color
from .unified_text_renderer import UnifiedTextRenderer
PatternKey: TypeAlias = Tuple[str, float]
DrawEntitiesCallback: TypeAlias = Callable[[RenderContext, Iterable[DXFGraphic]], None]
__all__ = ["AbstractPipeline", "RenderPipeline2d"]
class AbstractPipeline(abc.ABC):
"""This drawing pipeline separates the frontend from the backend and implements
these features:
- automatically linetype rendering
- font rendering
- VIEWPORT rendering
- foreground color mapping according Frontend.config.color_policy
The pipeline is organized as concatenated render stages.
"""
text_engine = UnifiedTextRenderer()
default_font_face = fonts.FontFace()
draw_entities: DrawEntitiesCallback
@abc.abstractmethod
def set_draw_entities_callback(self, callback: DrawEntitiesCallback) -> None: ...
@abc.abstractmethod
def set_config(self, config: Configuration) -> None: ...
@abc.abstractmethod
def set_current_entity_handle(self, handle: str) -> None: ...
@abc.abstractmethod
def push_clipping_shape(
self, shape: ClippingShape, transform: Matrix44 | None
) -> None: ...
@abc.abstractmethod
def pop_clipping_shape(self) -> None: ...
@abc.abstractmethod
def draw_viewport(
self,
vp: Viewport,
layout_ctx: RenderContext,
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
) -> None:
"""Draw the content of the given viewport current viewport."""
...
@abc.abstractmethod
def draw_point(self, pos: AnyVec, properties: Properties) -> None: ...
@abc.abstractmethod
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties): ...
@abc.abstractmethod
def draw_solid_lines(
self, lines: Iterable[tuple[AnyVec, AnyVec]], properties: Properties
) -> None: ...
@abc.abstractmethod
def draw_path(self, path: Path, properties: Properties): ...
@abc.abstractmethod
def draw_filled_paths(
self,
paths: Iterable[Path],
properties: Properties,
) -> None: ...
@abc.abstractmethod
def draw_filled_polygon(
self, points: Iterable[AnyVec], properties: Properties
) -> None: ...
@abc.abstractmethod
def draw_text(
self,
text: str,
transform: Matrix44,
properties: Properties,
cap_height: float,
dxftype: str = "TEXT",
) -> None: ...
@abc.abstractmethod
def draw_image(self, image_data: ImageData, properties: Properties) -> None: ...
@abc.abstractmethod
def finalize(self) -> None: ...
@abc.abstractmethod
def set_background(self, color: Color) -> None: ...
@abc.abstractmethod
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
# gets the full DXF properties information
...
@abc.abstractmethod
def exit_entity(self, entity: DXFGraphic) -> None: ...
class RenderStage2d(abc.ABC):
next_stage: RenderStage2d
def set_config(self, config: Configuration) -> None:
pass
@abc.abstractmethod
def draw_point(self, pos: Vec2, properties: Properties) -> None: ...
@abc.abstractmethod
def draw_line(self, start: Vec2, end: Vec2, properties: Properties): ...
@abc.abstractmethod
def draw_solid_lines(
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
) -> None: ...
@abc.abstractmethod
def draw_path(self, path: BkPath2d, properties: Properties): ...
@abc.abstractmethod
def draw_filled_paths(
self,
paths: list[BkPath2d],
properties: Properties,
) -> None: ...
@abc.abstractmethod
def draw_filled_polygon(
self, points: BkPoints2d, properties: Properties
) -> None: ...
@abc.abstractmethod
def draw_image(self, image_data: ImageData, properties: Properties) -> None: ...
class RenderPipeline2d(AbstractPipeline):
"""Render pipeline for 2D backends."""
def __init__(self, backend: BackendInterface):
self.backend = backend
self.config = Configuration()
try: # request default font face
self.default_font_face = fonts.font_manager.get_font_face("")
except fonts.FontNotFoundError: # no default font found
# last resort MonospaceFont which renders only "tofu"
pass
self.clipping_portal = ClippingPortal()
self.current_vp_scale = 1.0
self._current_entity_handle: str = ""
self._color_mapping: dict[str, str] = dict()
self._pipeline = self.build_render_pipeline()
def build_render_pipeline(self) -> RenderStage2d:
backend_stage = BackendStage2d(
self.backend, converter=self.get_backend_properties
)
linetype_stage = LinetypeStage2d(
self.config,
get_ltype_scale=self.get_vp_ltype_scale,
next_stage=backend_stage,
)
clipping_stage = ClippingStage2d(
self.config, self.clipping_portal, next_stage=linetype_stage
)
return clipping_stage
def get_vp_ltype_scale(self) -> float:
"""The linetype pattern should look the same in all viewports
regardless of the viewport scale.
"""
return 1.0 / max(self.current_vp_scale, 0.0001) # max out at 1:10000
def get_backend_properties(self, properties: Properties) -> BackendProperties:
try:
color = self._color_mapping[properties.color]
except KeyError:
color = apply_color_policy(
properties.color, self.config.color_policy, self.config.custom_fg_color
)
self._color_mapping[properties.color] = color
return BackendProperties(
color,
properties.lineweight,
properties.layer,
properties.pen,
self._current_entity_handle,
)
def set_draw_entities_callback(self, callback: DrawEntitiesCallback) -> None:
self.draw_entities = callback
def set_config(self, config: Configuration) -> None:
self.backend.configure(config)
self.config = config
stage = self._pipeline
while True:
stage.set_config(config)
if not hasattr(stage, "next_stage"): # BackendStage2d
return
stage = stage.next_stage
def set_current_entity_handle(self, handle: str) -> None:
assert handle is not None
self._current_entity_handle = handle
def push_clipping_shape(
self, shape: ClippingShape, transform: Matrix44 | None
) -> None:
self.clipping_portal.push(shape, transform)
def pop_clipping_shape(self) -> None:
self.clipping_portal.pop()
def draw_viewport(
self,
vp: Viewport,
layout_ctx: RenderContext,
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
) -> None:
"""Draw the content of the given viewport current viewport."""
if vp.doc is None:
return
try:
msp_limits = vp.get_modelspace_limits()
except ValueError: # modelspace limits not detectable
return
if self.enter_viewport(vp):
self.draw_entities(
layout_ctx.from_viewport(vp),
filter_vp_entities(vp.doc.modelspace(), msp_limits, bbox_cache),
)
self.exit_viewport()
def enter_viewport(self, vp: Viewport) -> bool:
"""Set current viewport, returns ``True`` for valid viewports."""
self.current_vp_scale = vp.get_scale()
m = vp.get_transformation_matrix()
clipping_path = make_path(vp)
if len(clipping_path):
vertices = clipping_path.control_vertices()
if clipping_path.has_curves:
layout = vp.get_layout()
if isinstance(layout, Layout):
# plot paper units:
# 0: inches, max sagitta = 1/254 = 0.1 mm
# 1: millimeters, max sagitta = 0.1 mm
# 2: pixels, max sagitta = 0.1 pixel
units = layout.dxf.get("plot_paper_units", 1)
max_sagitta = 1.0 / 254.0 if units == 0 else 0.1
vertices = list(clipping_path.flattening(max_sagitta))
clipping_shape = find_best_clipping_shape(vertices)
self.clipping_portal.push(clipping_shape, m)
return True
return False
def exit_viewport(self):
self.clipping_portal.pop()
# Reset viewport scaling: viewports cannot be nested!
self.current_vp_scale = 1.0
def draw_text(
self,
text: str,
transform: Matrix44,
properties: Properties,
cap_height: float,
dxftype: str = "TEXT",
) -> None:
"""Render text as filled paths."""
text_policy = self.config.text_policy
pipeline = self._pipeline
if not text.strip() or text_policy == TextPolicy.IGNORE:
return # no point rendering empty strings
text = prepare_string_for_rendering(text, dxftype)
font_face = properties.font
if font_face is None:
font_face = self.default_font_face
try:
glyph_paths = self.text_engine.get_text_glyph_paths(
text, font_face, cap_height
)
except (RuntimeError, ValueError):
return
for p in glyph_paths:
p.transform_inplace(transform)
transformed_paths: list[BkPath2d] = glyph_paths
points: list[Vec2]
if text_policy == TextPolicy.REPLACE_RECT:
points = []
for p in transformed_paths:
points.extend(p.extents())
if len(points) < 2:
return
rect = BkPath2d.from_vertices(BoundingBox2d(points).rect_vertices())
pipeline.draw_path(rect, properties)
return
if text_policy == TextPolicy.REPLACE_FILL:
points = []
for p in transformed_paths:
points.extend(p.extents())
if len(points) < 2:
return
polygon = BkPoints2d(BoundingBox2d(points).rect_vertices())
if properties.filling is None:
properties.filling = Filling()
pipeline.draw_filled_polygon(polygon, properties)
return
if (
self.text_engine.is_stroke_font(font_face)
or text_policy == TextPolicy.OUTLINE
):
for text_path in transformed_paths:
pipeline.draw_path(text_path, properties)
return
if properties.filling is None:
properties.filling = Filling()
pipeline.draw_filled_paths(transformed_paths, properties)
def finalize(self) -> None:
self.backend.finalize()
def set_background(self, color: Color) -> None:
self.backend.set_background(color)
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
self.backend.enter_entity(entity, properties)
def exit_entity(self, entity: DXFGraphic) -> None:
self.backend.exit_entity(entity)
# Enter render pipeline:
def draw_point(self, pos: AnyVec, properties: Properties) -> None:
self._pipeline.draw_point(Vec2(pos), properties)
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties):
self._pipeline.draw_line(Vec2(start), Vec2(end), properties)
def draw_solid_lines(
self, lines: Iterable[tuple[AnyVec, AnyVec]], properties: Properties
) -> None:
self._pipeline.draw_solid_lines(
[(Vec2(s), Vec2(e)) for s, e in lines], properties
)
def draw_path(self, path: Path, properties: Properties):
self._pipeline.draw_path(BkPath2d(path), properties)
def draw_filled_paths(
self,
paths: Iterable[Path],
properties: Properties,
) -> None:
self._pipeline.draw_filled_paths(list(map(BkPath2d, paths)), properties)
def draw_filled_polygon(
self, points: Iterable[AnyVec], properties: Properties
) -> None:
self._pipeline.draw_filled_polygon(BkPoints2d(points), properties)
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
self._pipeline.draw_image(image_data, properties)
class ClippingStage2d(RenderStage2d):
def __init__(
self,
config: Configuration,
clipping_portal: ClippingPortal,
next_stage: RenderStage2d,
):
self.clipping_portal = clipping_portal
self.config = config
self.next_stage = next_stage
def set_config(self, config: Configuration) -> None:
self.config = config
def draw_point(self, pos: Vec2, properties: Properties) -> None:
if self.clipping_portal.is_active:
pos = self.clipping_portal.clip_point(pos)
if pos is None:
return
self.next_stage.draw_point(pos, properties)
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
next_stage = self.next_stage
clipping_portal = self.clipping_portal
if clipping_portal.is_active:
for segment in clipping_portal.clip_line(start, end):
next_stage.draw_line(segment[0], segment[1], properties)
return
next_stage.draw_line(start, end, properties)
def draw_solid_lines(
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
) -> None:
clipping_portal = self.clipping_portal
if clipping_portal.is_active:
cropped_lines: list[tuple[Vec2, Vec2]] = []
for start, end in lines:
cropped_lines.extend(clipping_portal.clip_line(start, end))
lines = cropped_lines
self.next_stage.draw_solid_lines(lines, properties)
def draw_path(self, path: BkPath2d, properties: Properties):
clipping_portal = self.clipping_portal
next_stage = self.next_stage
max_sagitta = self.config.max_flattening_distance
if clipping_portal.is_active:
for clipped_path in clipping_portal.clip_paths([path], max_sagitta):
next_stage.draw_path(clipped_path, properties)
return
next_stage.draw_path(path, properties)
def draw_filled_paths(
self,
paths: list[BkPath2d],
properties: Properties,
) -> None:
clipping_portal = self.clipping_portal
max_sagitta = self.config.max_flattening_distance
if clipping_portal.is_active:
paths = clipping_portal.clip_filled_paths(paths, max_sagitta)
if len(paths) == 0:
return
self.next_stage.draw_filled_paths(paths, properties)
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
clipping_portal = self.clipping_portal
next_stage = self.next_stage
if clipping_portal.is_active:
for points in clipping_portal.clip_polygon(points):
if len(points) > 0:
next_stage.draw_filled_polygon(points, properties)
return
if len(points) > 0:
next_stage.draw_filled_polygon(points, properties)
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
# the outer bounds contain the visible parts of the image for the
# clip mode "remove inside"
outer_bounds: list[BkPoints2d] = []
clipping_portal = self.clipping_portal
if not clipping_portal.is_active:
self._draw_image(image_data, outer_bounds, properties)
return
# the pixel boundary path can be split into multiple paths
transform = image_data.flip_matrix() * image_data.transform
pixel_boundary_path = image_data.pixel_boundary_path
clipping_paths = _clip_image_polygon(
clipping_portal, pixel_boundary_path, transform
)
if not image_data.remove_outside:
# remove inside:
# detect the visible parts of the image which are not removed by
# clipping through viewports or block references
width, height = image_data.image_size()
outer_boundary = BkPoints2d(
Vec2.generate([(0, 0), (width, 0), (width, height), (0, height)])
)
outer_bounds = _clip_image_polygon(
clipping_portal, outer_boundary, transform
)
image_data.transform = clipping_portal.transform_matrix(image_data.transform)
if len(clipping_paths) == 1:
new_clipping_path = clipping_paths[0]
if new_clipping_path is not image_data.pixel_boundary_path:
image_data.pixel_boundary_path = new_clipping_path
# forced clipping triggered by viewport- or block reference clipping:
image_data.use_clipping_boundary = True
self._draw_image(image_data, outer_bounds, properties)
else:
for clipping_path in clipping_paths:
# when clipping path is split into multiple parts:
# copy image for each part, not efficient but works
# this should be a rare usecase so optimization is not required
self._draw_image(
ImageData(
image=image_data.image.copy(),
transform=image_data.transform,
pixel_boundary_path=clipping_path,
use_clipping_boundary=True,
),
outer_bounds,
properties,
)
def _draw_image(
self,
image_data: ImageData,
outer_bounds: list[BkPoints2d],
properties: Properties,
) -> None:
if image_data.use_clipping_boundary:
_mask_image(image_data, outer_bounds)
self.next_stage.draw_image(image_data, properties)
class LinetypeStage2d(RenderStage2d):
def __init__(
self,
config: Configuration,
get_ltype_scale: Callable[[], float],
next_stage: RenderStage2d,
):
self.config = config
self.solid_lines_only = False
self.next_stage = next_stage
self.get_ltype_scale = get_ltype_scale
self.pattern_cache: dict[PatternKey, Sequence[float]] = dict()
self.set_config(config)
def set_config(self, config: Configuration) -> None:
self.config = config
self.solid_lines_only = config.line_policy == LinePolicy.SOLID
def pattern(self, properties: Properties) -> Sequence[float]:
"""Returns simplified linetype tuple: on-off sequence"""
if self.solid_lines_only:
scale = 0.0
else:
scale = properties.linetype_scale * self.get_ltype_scale()
key: PatternKey = (properties.linetype_name, scale)
pattern_ = self.pattern_cache.get(key)
if pattern_ is None:
pattern_ = self._create_pattern(properties, scale)
self.pattern_cache[key] = pattern_
return pattern_
def _create_pattern(self, properties: Properties, scale: float) -> Sequence[float]:
if len(properties.linetype_pattern) < 2:
# Do not return None -> None indicates: "not cached"
return tuple()
min_dash_length = self.config.min_dash_length * self.get_ltype_scale()
pattern = [max(e * scale, min_dash_length) for e in properties.linetype_pattern]
if len(pattern) % 2:
pattern.pop()
return pattern
def draw_point(self, pos: Vec2, properties: Properties) -> None:
self.next_stage.draw_point(pos, properties)
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
s = Vec2(start)
e = Vec2(end)
next_stage = self.next_stage
if self.solid_lines_only or len(properties.linetype_pattern) < 2: # CONTINUOUS
next_stage.draw_line(s, e, properties)
return
renderer = linetypes.LineTypeRenderer(self.pattern(properties))
next_stage.draw_solid_lines(
[(s, e) for s, e in renderer.line_segment(s, e)],
properties,
)
def draw_solid_lines(
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
) -> None:
self.next_stage.draw_solid_lines(lines, properties)
def draw_path(self, path: BkPath2d, properties: Properties):
next_stage = self.next_stage
if self.solid_lines_only or len(properties.linetype_pattern) < 2: # CONTINUOUS
next_stage.draw_path(path, properties)
return
renderer = linetypes.LineTypeRenderer(self.pattern(properties))
vertices = path.flattening(self.config.max_flattening_distance, segments=16)
next_stage.draw_solid_lines(
[(Vec2(s), Vec2(e)) for s, e in renderer.line_segments(vertices)],
properties,
)
def draw_filled_paths(
self,
paths: list[BkPath2d],
properties: Properties,
) -> None:
self.next_stage.draw_filled_paths(paths, properties)
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
self.next_stage.draw_filled_polygon(points, properties)
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
self.next_stage.draw_image(image_data, properties)
class BackendStage2d(RenderStage2d):
"""Send data to the output backend."""
def __init__(
self,
backend: BackendInterface,
converter: Callable[[Properties], BackendProperties],
):
self.backend = backend
self.converter = converter
assert not hasattr(self, "next_stage"), "has to be the last render stage"
def draw_point(self, pos: Vec2, properties: Properties) -> None:
self.backend.draw_point(pos, self.converter(properties))
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
self.backend.draw_line(start, end, self.converter(properties))
def draw_solid_lines(
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
) -> None:
self.backend.draw_solid_lines(lines, self.converter(properties))
def draw_path(self, path: BkPath2d, properties: Properties):
self.backend.draw_path(path, self.converter(properties))
def draw_filled_paths(
self,
paths: list[BkPath2d],
properties: Properties,
) -> None:
self.backend.draw_filled_paths(paths, self.converter(properties))
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
self.backend.draw_filled_polygon(points, self.converter(properties))
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
self.backend.draw_image(image_data, self.converter(properties))
def _mask_image(image_data: ImageData, outer_bounds: list[BkPoints2d]) -> None:
"""Mask away the clipped parts of the image. The argument `outer_bounds` is only
used for clip mode "remove_inside". The outer bounds can be composed of multiple
parts. If `outer_bounds` is empty the image has no removed parts and is fully
visible before applying the image clipping path.
Args:
image_data:
image_data.pixel_boundary: path contains the image clipping path
image_data.remove_outside: defines the clipping mode (inside/outside)
outer_bounds: countain the parts of the image which are __not__ removed by
clipping through viewports or clipped block references
e.g. an image without any removed parts has the outer bounds
[(0, 0) (width, 0), (width, height), (0, height)]
"""
clip_polygon = [(p.x, p.y) for p in image_data.pixel_boundary_path.vertices()]
# create an empty image
clipping_image = PIL.Image.new("L", image_data.image_size(), 0)
# paint in the clipping path
PIL.ImageDraw.ImageDraw(clipping_image).polygon(
clip_polygon, outline=None, width=0, fill=1
)
clipping_mask = np.asarray(clipping_image)
if not image_data.remove_outside: # clip mode "remove_inside"
if outer_bounds:
# create a new empty image
visible_image = PIL.Image.new("L", image_data.image_size(), 0)
# paint in parts of the image which are still visible
for boundary in outer_bounds:
clip_polygon = [(p.x, p.y) for p in boundary.vertices()]
PIL.ImageDraw.ImageDraw(visible_image).polygon(
clip_polygon, outline=None, width=0, fill=1
)
# remove the clipping path
clipping_mask = np.asarray(visible_image) - clipping_mask
else:
# create mask for fully visible image
fully_visible_image_mask = np.full(
clipping_mask.shape, fill_value=1, dtype=clipping_mask.dtype
)
# remove the clipping path
clipping_mask = fully_visible_image_mask - clipping_mask
image_data.image[:, :, 3] *= clipping_mask
def _clip_image_polygon(
clipping_portal: ClippingPortal, polygon_px: BkPoints2d, m: Matrix44
) -> list[BkPoints2d]:
original = [polygon_px]
# inverse matrix includes the transformation applied by the clipping portal
inverse = clipping_portal.transform_matrix(m)
try:
inverse.inverse()
except ZeroDivisionError:
# inverse transformation from WCS to pixel coordinates is not possible
return original
# transform image coordinates to WCS coordinates
polygon = polygon_px.clone()
polygon.transform_inplace(m)
clipped_polygons = clipping_portal.clip_polygon(polygon)
if (len(clipped_polygons) == 1) and (clipped_polygons[0] is polygon):
# this shows the caller that the image boundary path wasn't clipped
return original
# transform WCS coordinates to image coordinates
for polygon in clipped_polygons:
polygon.transform_inplace(inverse)
return clipped_polygons # in image coordinates!
def invert_color(color: Color) -> Color:
r, g, b = RGB.from_hex(color)
return RGB(255 - r, 255 - g, 255 - b).to_hex()
def swap_bw(color: str) -> Color:
color = color.lower()
if color == "#000000":
return "#ffffff"
if color == "#ffffff":
return "#000000"
return color
def color_to_monochrome(color: Color, scale: float = 1.0, offset: float = 0.0) -> Color:
lum = RGB.from_hex(color).luminance * scale + offset
if lum < 0.0:
lum = 0.0
elif lum > 1.0:
lum = 1.0
gray = round(lum * 255)
return RGB(gray, gray, gray).to_hex()
def apply_color_policy(color: Color, policy: ColorPolicy, custom_color: Color) -> Color:
alpha = color[7:9]
color = color[:7]
if policy == ColorPolicy.COLOR_SWAP_BW:
color = swap_bw(color)
elif policy == ColorPolicy.COLOR_NEGATIVE:
color = invert_color(color)
elif policy == ColorPolicy.MONOCHROME_DARK_BG: # [0.3, 1.0]
color = color_to_monochrome(color, scale=0.7, offset=0.3)
elif policy == ColorPolicy.MONOCHROME_LIGHT_BG: # [0.0, 0.7]
color = color_to_monochrome(color, scale=0.7, offset=0.0)
elif policy == ColorPolicy.MONOCHROME: # [0.0, 1.0]
color = color_to_monochrome(color)
elif policy == ColorPolicy.BLACK:
color = "#000000"
elif policy == ColorPolicy.WHITE:
color = "#ffffff"
elif policy == ColorPolicy.CUSTOM:
fg = custom_color
color = fg[:7]
alpha = fg[7:9]
return color + alpha
def filter_vp_entities(
msp: Layout,
limits: Sequence[float],
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
) -> Iterator[DXFGraphic]:
"""Yields all DXF entities that need to be processed by the given viewport
`limits`. The entities may be partially of even complete outside the viewport.
By passing the bounding box cache of the modelspace entities,
the function can filter entities outside the viewport to speed up rendering
time.
There are two processing modes for the `bbox_cache`:
1. The `bbox_cache` is``None``: all entities must be processed,
pass through mode
2. If the `bbox_cache` is given but does not contain an entity,
the bounding box is computed and added to the cache.
Even passing in an empty cache can speed up rendering time when
multiple viewports need to be processed.
Args:
msp: modelspace layout
limits: modelspace limits of the viewport, as tuple (min_x, min_y, max_x, max_y)
bbox_cache: the bounding box cache of the modelspace entities
"""
# WARNING: this works only with top-view viewports
# The current state of the drawing add-on supports only top-view viewports!
def is_visible(e):
entity_bbox = bbox_cache.get(e)
if entity_bbox is None:
# compute and add bounding box
entity_bbox = ezdxf.bbox.extents((e,), fast=True, cache=bbox_cache)
if not entity_bbox.has_data:
return True
# Check for separating axis:
if min_x >= entity_bbox.extmax.x:
return False
if max_x <= entity_bbox.extmin.x:
return False
if min_y >= entity_bbox.extmax.y:
return False
if max_y <= entity_bbox.extmin.y:
return False
return True
if bbox_cache is None: # pass through all entities
yield from msp
return
min_x, min_y, max_x, max_y = limits
if not bbox_cache.has_data:
# fill cache at once
ezdxf.bbox.extents(msp, fast=True, cache=bbox_cache)
for entity in msp:
if is_visible(entity):
yield entity
def prepare_string_for_rendering(text: str, dxftype: str) -> str:
assert "\n" not in text, "not a single line of text"
if dxftype in {"TEXT", "ATTRIB", "ATTDEF"}:
text = replace_non_printable_characters(text, replacement="?")
text = text.replace("\t", "?")
elif dxftype == "MTEXT":
text = replace_non_printable_characters(text, replacement="")
text = text.replace("\t", " ")
else:
raise TypeError(dxftype)
return text
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,486 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
import math
from typing import Iterable, no_type_check, Any
import copy
import PIL.Image
import numpy as np
from ezdxf.math import Vec2, BoundingBox2d
from ezdxf.colors import RGB
from ezdxf.path import Command
from ezdxf.version import __version__
from ezdxf.lldxf.validator import make_table_key as layer_key
from .type_hints import Color
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import Configuration, LineweightPolicy
from .properties import BackendProperties
from . import layout, recorder
is_pymupdf_installed = True
pymupdf: Any = None
try:
import pymupdf # type: ignore[import-untyped, no-redef]
except ImportError:
print(
"Python module PyMuPDF (AGPL!) is required: https://pypi.org/project/PyMuPDF/"
)
is_pymupdf_installed = False
# PyMuPDF docs: https://pymupdf.readthedocs.io/en/latest/
__all__ = ["PyMuPdfBackend", "is_pymupdf_installed"]
# PDF units are points (pt), 1 pt is 1/72 of an inch:
MM_TO_POINTS = 72.0 / 25.4 # 25.4 mm = 1 inch / 72
# psd does not work in PyMuPDF v1.22.3
SUPPORTED_IMAGE_FORMATS = ("png", "ppm", "pbm")
class PyMuPdfBackend(recorder.Recorder):
"""This backend uses the `PyMuPdf`_ package to create PDF, PNG, PPM and PBM output.
This backend support content cropping at page margins.
PyMuPDF is licensed under the `AGPL`_. Sorry, but it's the best package for the job
I've found so far.
Install package::
pip install pymupdf
.. _PyMuPdf: https://pypi.org/project/PyMuPDF/
.. _AGPL: https://www.gnu.org/licenses/agpl-3.0.html
"""
def __init__(self) -> None:
super().__init__()
self._init_flip_y = True
def get_replay(
self,
page: layout.Page,
*,
settings: layout.Settings = layout.Settings(),
render_box: BoundingBox2d | None = None,
) -> PyMuPdfRenderBackend:
"""Returns the PDF document as bytes.
Args:
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
render_box: set explicit region to render, default is content bounding box
"""
top_origin = True
# This player changes the original recordings!
player = self.player()
if render_box is None:
render_box = player.bbox()
# the page origin (0, 0) is in the top-left corner.
output_layout = layout.Layout(render_box, flip_y=self._init_flip_y)
page = output_layout.get_final_page(page, settings)
# DXF coordinates are mapped to PDF Units in the first quadrant
settings = copy.copy(settings)
settings.output_coordinate_space = get_coordinate_output_space(page)
m = output_layout.get_placement_matrix(
page, settings=settings, top_origin=top_origin
)
# transform content to the output coordinates space:
player.transform(m)
if settings.crop_at_margins:
p1, p2 = page.get_margin_rect(top_origin=top_origin) # in mm
# scale factor to map page coordinates to output space coordinates:
output_scale = settings.page_output_scale_factor(page)
max_sagitta = 0.1 * MM_TO_POINTS # curve approximation 0.1 mm
# crop content inplace by the margin rect:
player.crop_rect(p1 * output_scale, p2 * output_scale, max_sagitta)
self._init_flip_y = False
backend = self.make_backend(page, settings)
player.replay(backend)
return backend
def get_pdf_bytes(
self,
page: layout.Page,
*,
settings: layout.Settings = layout.Settings(),
render_box: BoundingBox2d | None = None,
) -> bytes:
"""Returns the PDF document as bytes.
Args:
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
render_box: set explicit region to render, default is content bounding box
"""
backend = self.get_replay(page, settings=settings, render_box=render_box)
return backend.get_pdf_bytes()
def get_pixmap_bytes(
self,
page: layout.Page,
*,
fmt="png",
settings: layout.Settings = layout.Settings(),
dpi: int = 96,
alpha=False,
render_box: BoundingBox2d | None = None,
) -> bytes:
"""Returns a pixel image as bytes, supported image formats:
=== =========================
png Portable Network Graphics
ppm Portable Pixmap (no alpha channel)
pbm Portable Bitmap (no alpha channel)
=== =========================
Args:
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
fmt: image format
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
dpi: output resolution in dots per inch
alpha: add alpha channel (transparency)
render_box: set explicit region to render, default is content bounding box
"""
if fmt not in SUPPORTED_IMAGE_FORMATS:
raise ValueError(f"unsupported image format: '{fmt}'")
backend = self.get_replay(page, settings=settings, render_box=render_box)
try:
pixmap = backend.get_pixmap(dpi=dpi, alpha=alpha)
return pixmap.tobytes(output=fmt)
except RuntimeError as e:
print(f"PyMuPDF Runtime Error: {str(e)}")
return b""
@staticmethod
def make_backend(
page: layout.Page, settings: layout.Settings
) -> PyMuPdfRenderBackend:
"""Override this method to use a customized render backend."""
return PyMuPdfRenderBackend(page, settings)
def get_coordinate_output_space(page: layout.Page) -> int:
page_width_in_pt = int(page.width_in_mm * MM_TO_POINTS)
page_height_in_pt = int(page.height_in_mm * MM_TO_POINTS)
return max(page_width_in_pt, page_height_in_pt)
class PyMuPdfRenderBackend(BackendInterface):
"""Creates the PDF/PNG/PSD/SVG output.
This backend requires some preliminary work, record the frontend output via the
Recorder backend to accomplish the following requirements:
- Move content in the first quadrant of the coordinate system.
- The page is defined by the upper left corner in the origin (0, 0) and
the lower right corner at (page-width, page-height)
- The output coordinates are floats in 1/72 inch, scale the content appropriately
- Replay the recorded output on this backend.
.. important::
Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/
"""
def __init__(self, page: layout.Page, settings: layout.Settings) -> None:
assert (
is_pymupdf_installed
), "Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/"
self.doc = pymupdf.open()
self.doc.set_metadata(
{
"producer": f"PyMuPDF {pymupdf.version[0]}",
"creator": f"ezdxf {__version__}",
}
)
self.settings = settings
self._optional_content_groups: dict[str, int] = {}
self._stroke_width_cache: dict[float, float] = {}
self._color_cache: dict[str, tuple[float, float, float]] = {}
self.page_width_in_pt = int(page.width_in_mm * MM_TO_POINTS)
self.page_height_in_pt = int(page.height_in_mm * MM_TO_POINTS)
# LineweightPolicy.ABSOLUTE:
self.min_lineweight = 0.05 # in mm, set by configure()
self.lineweight_scaling = 1.0 # set by configure()
self.lineweight_policy = LineweightPolicy.ABSOLUTE # set by configure()
# when the stroke width is too thin PDF viewers may get confused;
self.abs_min_stroke_width = 0.1 # pt == 0.03528mm (arbitrary choice)
# LineweightPolicy.RELATIVE:
# max_stroke_width is determined as a certain percentage of settings.output_coordinate_space
self.max_stroke_width: float = max(
self.abs_min_stroke_width,
int(settings.output_coordinate_space * settings.max_stroke_width),
)
# min_stroke_width is determined as a certain percentage of max_stroke_width
self.min_stroke_width: float = max(
self.abs_min_stroke_width,
int(self.max_stroke_width * settings.min_stroke_width),
)
# LineweightPolicy.RELATIVE_FIXED:
# all strokes have a fixed stroke-width as a certain percentage of max_stroke_width
self.fixed_stroke_width: float = max(
self.abs_min_stroke_width,
int(self.max_stroke_width * settings.fixed_stroke_width),
)
self.page = self.doc.new_page(-1, self.page_width_in_pt, self.page_height_in_pt)
# The page content is stored in a shared shape:
self.content_shape = self.page.new_shape()
# see also: https://github.com/pymupdf/PyMuPDF/issues/3800
def get_pdf_bytes(self) -> bytes:
return self.doc.tobytes()
def get_pixmap(self, dpi: int, alpha=False):
return self.page.get_pixmap(dpi=dpi, alpha=alpha)
def get_svg_image(self) -> str:
return self.page.get_svg_image()
def set_background(self, color: Color) -> None:
rgb = self.resolve_color(color)
opacity = alpha_to_opacity(color[7:9])
if color == (1.0, 1.0, 1.0) or opacity == 0.0:
return
shape = self.content_shape
shape.draw_rect([0, 0, self.page_width_in_pt, self.page_height_in_pt])
shape.finish(width=None, color=None, fill=rgb, fill_opacity=opacity)
shape.commit()
def get_optional_content_group(self, layer_name: str) -> int:
if not self.settings.output_layers:
return 0 # the default value of `oc` when not provided
layer_name = layer_key(layer_name)
if layer_name not in self._optional_content_groups:
self._optional_content_groups[layer_name] = self.doc.add_ocg(
name=layer_name,
config=-1,
on=True,
)
return self._optional_content_groups[layer_name]
def finish_line(self, shape, properties: BackendProperties, close: bool) -> None:
color = self.resolve_color(properties.color)
width = self.resolve_stroke_width(properties.lineweight)
shape.finish(
width=width,
color=color,
fill=None,
lineJoin=1,
lineCap=1,
stroke_opacity=alpha_to_opacity(properties.color[7:9]),
closePath=close,
oc=self.get_optional_content_group(properties.layer),
)
def finish_filling(self, shape, properties: BackendProperties) -> None:
shape.finish(
width=None,
color=None,
fill=self.resolve_color(properties.color),
fill_opacity=alpha_to_opacity(properties.color[7:9]),
lineJoin=1,
lineCap=1,
closePath=True,
even_odd=True,
oc=self.get_optional_content_group(properties.layer),
)
def resolve_color(self, color: Color) -> tuple[float, float, float]:
key = color[:7]
try:
return self._color_cache[key]
except KeyError:
pass
color_floats = RGB.from_hex(color).to_floats()
self._color_cache[key] = color_floats
return color_floats
def resolve_stroke_width(self, width: float) -> float:
try:
return self._stroke_width_cache[width]
except KeyError:
pass
stroke_width = self.min_stroke_width
if self.lineweight_policy == LineweightPolicy.ABSOLUTE:
stroke_width = ( # in points (pt) = 1/72 inch
max(self.min_lineweight, width) * MM_TO_POINTS * self.lineweight_scaling
)
elif self.lineweight_policy == LineweightPolicy.RELATIVE:
stroke_width = map_lineweight_to_stroke_width(
width, self.min_stroke_width, self.max_stroke_width
)
stroke_width = max(self.abs_min_stroke_width, stroke_width)
self._stroke_width_cache[width] = stroke_width
return stroke_width
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
shape = self.content_shape
pos = Vec2(pos)
shape.draw_line(pos, pos)
self.finish_line(shape, properties, close=False)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
shape = self.content_shape
shape.draw_line(Vec2(start), Vec2(end))
self.finish_line(shape, properties, close=False)
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
shape = self.content_shape
for start, end in lines:
shape.draw_line(start, end)
self.finish_line(shape, properties, close=False)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
if len(path) == 0:
return
shape = self.content_shape
add_path_to_shape(shape, path, close=False)
self.finish_line(shape, properties, close=False)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
shape = self.content_shape
for p in paths:
add_path_to_shape(shape, p, close=True)
self.finish_filling(shape, properties)
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
vertices = points.to_list()
if len(vertices) < 3:
return
# pymupdf >= 1.23.19 does not accept Vec2() instances
# input coordinates are page coordinates in pdf units
shape = self.content_shape
shape.draw_polyline(vertices)
self.finish_filling(shape, properties)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
transform = image_data.transform
image = image_data.image
height, width, depth = image.shape
assert depth == 4
corners = list(
transform.transform_vertices(
[Vec2(0, 0), Vec2(width, 0), Vec2(width, height), Vec2(0, height)]
)
)
xs = [p.x for p in corners]
ys = [p.y for p in corners]
r = pymupdf.Rect((min(xs), min(ys)), (max(xs), max(ys)))
# translation and non-uniform scale are handled by having the image stretch to fill the given rect.
angle = (corners[1] - corners[0]).angle_deg
need_rotate = not math.isclose(angle, 0.0)
# already mirroring once to go from pixels (+y down) to wcs (+y up)
# so a positive determinant means an additional reflection
need_flip = transform.determinant() > 0
if need_rotate or need_flip:
pil_image = PIL.Image.fromarray(image, mode="RGBA")
if need_flip:
pil_image = pil_image.transpose(PIL.Image.Transpose.FLIP_TOP_BOTTOM)
if need_rotate:
pil_image = pil_image.rotate(
-angle,
resample=PIL.Image.Resampling.BICUBIC,
expand=True,
fillcolor=(0, 0, 0, 0),
)
image = np.asarray(pil_image)
height, width, depth = image.shape
pixmap = pymupdf.Pixmap(
pymupdf.Colorspace(pymupdf.CS_RGB), width, height, bytes(image.data), True
)
# TODO: could improve by caching and re-using xrefs. If a document contains many
# identical images redundant copies will be stored for each one
self.page.insert_image(
r,
keep_proportion=False,
pixmap=pixmap,
oc=self.get_optional_content_group(properties.layer),
)
def configure(self, config: Configuration) -> None:
self.lineweight_policy = config.lineweight_policy
if config.min_lineweight:
# config.min_lineweight in 1/300 inch!
min_lineweight_mm = config.min_lineweight * 25.4 / 300
self.min_lineweight = max(0.05, min_lineweight_mm)
self.lineweight_scaling = config.lineweight_scaling
def clear(self) -> None:
pass
def finalize(self) -> None:
self.content_shape.commit()
def enter_entity(self, entity, properties) -> None:
pass
def exit_entity(self, entity) -> None:
pass
@no_type_check
def add_path_to_shape(shape, path: BkPath2d, close: bool) -> None:
start = path.start
sub_path_start = start
last_point = start
for command in path.commands():
end = command.end
if command.type == Command.MOVE_TO:
if close and not sub_path_start.isclose(end):
shape.draw_line(start, sub_path_start)
sub_path_start = end
elif command.type == Command.LINE_TO:
shape.draw_line(start, end)
elif command.type == Command.CURVE3_TO:
shape.draw_curve(start, command.ctrl, end)
elif command.type == Command.CURVE4_TO:
shape.draw_bezier(start, command.ctrl1, command.ctrl2, end)
start = end
last_point = end
if close and not sub_path_start.isclose(last_point):
shape.draw_line(last_point, sub_path_start)
def map_lineweight_to_stroke_width(
lineweight: float,
min_stroke_width: float,
max_stroke_width: float,
min_lineweight=0.05, # defined by DXF
max_lineweight=2.11, # defined by DXF
) -> float:
"""Map the DXF lineweight in mm to stroke-width in viewBox coordinates."""
lineweight = max(min(lineweight, max_lineweight), min_lineweight) - min_lineweight
factor = (max_stroke_width - min_stroke_width) / (max_lineweight - min_lineweight)
return min_stroke_width + round(lineweight * factor, 1)
def alpha_to_opacity(alpha: str) -> float:
# stroke-opacity: 0.0 = transparent; 1.0 = opaque
# alpha: "00" = transparent; "ff" = opaque
if len(alpha):
try:
return int(alpha, 16) / 255
except ValueError:
pass
return 1.0
@@ -0,0 +1,314 @@
# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
# mypy: ignore_errors=True
from __future__ import annotations
from typing import Optional, Iterable
import abc
import math
import numpy as np
from ezdxf.addons.xqt import QtCore as qc, QtGui as qg, QtWidgets as qw
from ezdxf.addons.drawing.backend import Backend, BkPath2d, BkPoints2d, ImageData
from ezdxf.addons.drawing.config import Configuration
from ezdxf.addons.drawing.type_hints import Color
from ezdxf.addons.drawing.properties import BackendProperties
from ezdxf.math import Vec2, Matrix44
from ezdxf.npshapes import to_qpainter_path
class _Point(qw.QAbstractGraphicsShapeItem):
"""A dimensionless point which is drawn 'cosmetically' (scale depends on
view)
"""
def __init__(self, x: float, y: float, brush: qg.QBrush):
super().__init__()
self.location = qc.QPointF(x, y)
self.radius = 1.0
self.setPen(qg.QPen(qc.Qt.NoPen))
self.setBrush(brush)
def paint(
self,
painter: qg.QPainter,
option: qw.QStyleOptionGraphicsItem,
widget: Optional[qw.QWidget] = None,
) -> None:
view_scale = _get_x_scale(painter.transform())
radius = self.radius / view_scale
painter.setBrush(self.brush())
painter.setPen(qc.Qt.NoPen)
painter.drawEllipse(self.location, radius, radius)
def boundingRect(self) -> qc.QRectF:
return qc.QRectF(self.location, qc.QSizeF(1, 1))
# The key used to store the dxf entity corresponding to each graphics element
CorrespondingDXFEntity = qc.Qt.UserRole + 0 # type: ignore
CorrespondingDXFParentStack = qc.Qt.UserRole + 1 # type: ignore
class _PyQtBackend(Backend):
"""
Abstract PyQt backend which uses the :mod:`PySide6` package to implement an
interactive viewer. The :mod:`PyQt5` package can be used as fallback if the
:mod:`PySide6` package is not available.
"""
def __init__(self, scene: qw.QGraphicsScene):
super().__init__()
self._scene = scene
self._color_cache: dict[Color, qg.QColor] = {}
self._no_line = qg.QPen(qc.Qt.NoPen)
self._no_fill = qg.QBrush(qc.Qt.NoBrush)
def configure(self, config: Configuration) -> None:
if config.min_lineweight is None:
config = config.with_changes(min_lineweight=0.24)
super().configure(config)
def set_scene(self, scene: qw.QGraphicsScene) -> None:
self._scene = scene
def _add_item(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
self.set_item_data(item, entity_handle)
self._scene.addItem(item)
@abc.abstractmethod
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
...
def _get_color(self, color: Color) -> qg.QColor:
try:
return self._color_cache[color]
except KeyError:
pass
if len(color) == 7:
qt_color = qg.QColor(color) # '#RRGGBB'
elif len(color) == 9:
rgb = color[1:7]
alpha = color[7:9]
qt_color = qg.QColor(f"#{alpha}{rgb}") # '#AARRGGBB'
else:
raise TypeError(color)
self._color_cache[color] = qt_color
return qt_color
def _get_pen(self, properties: BackendProperties) -> qg.QPen:
"""Returns a cosmetic pen with applied lineweight but without line type
support.
"""
px = properties.lineweight / 0.3527 * self.config.lineweight_scaling
pen = qg.QPen(self._get_color(properties.color), px)
# Use constant width in pixel:
pen.setCosmetic(True)
pen.setJoinStyle(qc.Qt.RoundJoin)
return pen
def _get_fill_brush(self, color: Color) -> qg.QBrush:
return qg.QBrush(self._get_color(color), qc.Qt.SolidPattern) # type: ignore
def set_background(self, color: Color):
self._scene.setBackgroundBrush(qg.QBrush(self._get_color(color)))
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
"""Draw a real dimensionless point."""
brush = self._get_fill_brush(properties.color)
item = _Point(pos.x, pos.y, brush)
self._add_item(item, properties.handle)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
# PyQt draws a long line for a zero-length line:
if start.isclose(end):
self.draw_point(start, properties)
else:
item = qw.QGraphicsLineItem(start.x, start.y, end.x, end.y)
item.setPen(self._get_pen(properties))
self._add_item(item, properties.handle)
def draw_solid_lines(
self,
lines: Iterable[tuple[Vec2, Vec2]],
properties: BackendProperties,
):
"""Fast method to draw a bunch of solid lines with the same properties."""
pen = self._get_pen(properties)
add_line = self._add_item
for s, e in lines:
if s.isclose(e):
self.draw_point(s, properties)
else:
item = qw.QGraphicsLineItem(s.x, s.y, e.x, e.y)
item.setPen(pen)
add_line(item, properties.handle)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
if len(path) == 0:
return
item = qw.QGraphicsPathItem(to_qpainter_path([path]))
item.setPen(self._get_pen(properties))
item.setBrush(self._no_fill)
self._add_item(item, properties.handle)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
# Default fill rule is OddEvenFill! Detecting the path orientation is not
# necessary!
_paths = list(paths)
if len(_paths) == 0:
return
item = _CosmeticPath(to_qpainter_path(_paths))
item.setPen(self._get_pen(properties))
item.setBrush(self._get_fill_brush(properties.color))
self._add_item(item, properties.handle)
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
brush = self._get_fill_brush(properties.color)
polygon = qg.QPolygonF()
for p in points.vertices():
polygon.append(qc.QPointF(p.x, p.y))
item = _CosmeticPolygon(polygon)
item.setPen(self._no_line)
item.setBrush(brush)
self._add_item(item, properties.handle)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
image = image_data.image
transform = image_data.transform
height, width, depth = image.shape
assert depth == 4
bytes_per_row = width * depth
image = np.ascontiguousarray(np.flip(image, axis=0))
pixmap = qg.QPixmap(
qg.QImage(
image.data,
width,
height,
bytes_per_row,
qg.QImage.Format.Format_RGBA8888,
)
)
item = qw.QGraphicsPixmapItem()
item.setPixmap(pixmap)
item.setTransformationMode(qc.Qt.TransformationMode.SmoothTransformation)
item.setTransform(_matrix_to_qtransform(transform))
self._add_item(item, properties.handle)
def clear(self) -> None:
self._scene.clear()
def finalize(self) -> None:
super().finalize()
self._scene.setSceneRect(self._scene.itemsBoundingRect())
class PyQtBackend(_PyQtBackend):
"""
Backend which uses the :mod:`PySide6` package to implement an interactive
viewer. The :mod:`PyQt5` package can be used as fallback if the :mod:`PySide6`
package is not available.
Args:
scene: drawing canvas of type :class:`QtWidgets.QGraphicsScene`,
if ``None`` a new canvas will be created
"""
def __init__(
self,
scene: Optional[qw.QGraphicsScene] = None,
):
super().__init__(scene or qw.QGraphicsScene())
# This implementation keeps all virtual entities alive by attaching references
# to entities to the graphic scene items.
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
parent_stack = tuple(e for e, props in self.entity_stack[:-1])
current_entity = self.current_entity
item.setData(CorrespondingDXFEntity, current_entity)
item.setData(CorrespondingDXFParentStack, parent_stack)
class PyQtPlaybackBackend(_PyQtBackend):
"""
Backend which uses the :mod:`PySide6` package to implement an interactive
viewer. The :mod:`PyQt5` package can be used as fallback if the :mod:`PySide6`
package is not available.
This backend can be used a playback backend for the :meth:`replay` method of the
:class:`Player` class
Args:
scene: drawing canvas of type :class:`QtWidgets.QGraphicsScene`,
if ``None`` a new canvas will be created
"""
def __init__(
self,
scene: Optional[qw.QGraphicsScene] = None,
):
super().__init__(scene or qw.QGraphicsScene())
# The backend recorder does not record enter_entity() and exit_entity() events.
# This implementation attaches only entity handles (str) to the graphic scene items.
# Each item references the top level entity e.g. all items of a block reference
# references the handle of the INSERT entity.
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
item.setData(CorrespondingDXFEntity, entity_handle)
class _CosmeticPath(qw.QGraphicsPathItem):
def paint(
self,
painter: qg.QPainter,
option: qw.QStyleOptionGraphicsItem,
widget: Optional[qw.QWidget] = None,
) -> None:
_set_cosmetic_brush(self, painter)
super().paint(painter, option, widget)
class _CosmeticPolygon(qw.QGraphicsPolygonItem):
def paint(
self,
painter: qg.QPainter,
option: qw.QStyleOptionGraphicsItem,
widget: Optional[qw.QWidget] = None,
) -> None:
_set_cosmetic_brush(self, painter)
super().paint(painter, option, widget)
def _set_cosmetic_brush(
item: qw.QAbstractGraphicsShapeItem, painter: qg.QPainter
) -> None:
"""like a cosmetic pen, this sets the brush pattern to appear the same independent of the view"""
brush = item.brush()
# scale by -1 in y because the view is always mirrored in y and undoing the view transformation entirely would make
# the hatch mirrored w.r.t the view
brush.setTransform(painter.transform().inverted()[0].scale(1, -1)) # type: ignore
item.setBrush(brush)
def _get_x_scale(t: qg.QTransform) -> float:
return math.sqrt(t.m11() * t.m11() + t.m21() * t.m21())
def _matrix_to_qtransform(matrix: Matrix44) -> qg.QTransform:
"""Qt also uses row-vectors so the translation elements are placed in the
bottom row.
This is only a simple conversion which assumes that although the
transformation is 4x4,it does not involve the z axis.
A more correct transformation could be implemented like so:
https://stackoverflow.com/questions/10629737/convert-3d-4x4-rotation-matrix-into-2d
"""
return qg.QTransform(*matrix.get_2d_transformation())
@@ -0,0 +1,623 @@
#!/usr/bin/env python3
# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
# mypy: ignore_errors=True
from __future__ import annotations
from typing import Iterable, Sequence, Set, Optional
import math
import os
import time
from ezdxf.addons.xqt import QtWidgets as qw, QtCore as qc, QtGui as qg
from ezdxf.addons.xqt import Slot, QAction, Signal
import ezdxf
import ezdxf.bbox
from ezdxf import recover
from ezdxf.addons import odafc
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.config import Configuration
from ezdxf.addons.drawing.properties import (
is_dark_color,
set_layers_state,
LayerProperties,
)
from ezdxf.addons.drawing.pyqt import (
_get_x_scale,
PyQtBackend,
CorrespondingDXFEntity,
CorrespondingDXFParentStack,
)
from ezdxf.audit import Auditor
from ezdxf.document import Drawing
from ezdxf.entities import DXFGraphic, DXFEntity
from ezdxf.layouts import Layout
from ezdxf.lldxf.const import DXFStructureError
class CADGraphicsView(qw.QGraphicsView):
closing = Signal()
def __init__(
self,
*,
view_buffer: float = 0.2,
zoom_per_scroll_notch: float = 0.2,
loading_overlay: bool = True,
):
super().__init__()
self._zoom = 1.0
self._default_zoom = 1.0
self._zoom_limits = (0.5, 100)
self._zoom_per_scroll_notch = zoom_per_scroll_notch
self._view_buffer = view_buffer
self._loading_overlay = loading_overlay
self._is_loading = False
self.setTransformationAnchor(qw.QGraphicsView.AnchorUnderMouse)
self.setResizeAnchor(qw.QGraphicsView.AnchorUnderMouse)
self.setVerticalScrollBarPolicy(qc.Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(qc.Qt.ScrollBarAlwaysOff)
self.setDragMode(qw.QGraphicsView.ScrollHandDrag)
self.setFrameShape(qw.QFrame.NoFrame)
self.setRenderHints(
qg.QPainter.Antialiasing
| qg.QPainter.TextAntialiasing
| qg.QPainter.SmoothPixmapTransform
)
self.setScene(qw.QGraphicsScene())
self.scale(1, -1) # so that +y is up
def closeEvent(self, event: qg.QCloseEvent) -> None:
super().closeEvent(event)
self.closing.emit()
def clear(self):
pass
def begin_loading(self):
self._is_loading = True
self.scene().invalidate(qc.QRectF(), qw.QGraphicsScene.AllLayers)
qw.QApplication.processEvents()
def end_loading(self, new_scene: qw.QGraphicsScene):
self.setScene(new_scene)
self._is_loading = False
self.buffer_scene_rect()
self.scene().invalidate(qc.QRectF(), qw.QGraphicsScene.AllLayers)
def buffer_scene_rect(self):
scene = self.scene()
r = scene.sceneRect()
bx, by = (
r.width() * self._view_buffer / 2,
r.height() * self._view_buffer / 2,
)
scene.setSceneRect(r.adjusted(-bx, -by, bx, by))
def fit_to_scene(self):
self.fitInView(self.sceneRect(), qc.Qt.KeepAspectRatio)
self._default_zoom = _get_x_scale(self.transform())
self._zoom = 1
def _get_zoom_amount(self) -> float:
return _get_x_scale(self.transform()) / self._default_zoom
def wheelEvent(self, event: qg.QWheelEvent) -> None:
# dividing by 120 gets number of notches on a typical scroll wheel.
# See QWheelEvent documentation
delta_notches = event.angleDelta().y() / 120
direction = math.copysign(1, delta_notches)
factor = (1.0 + self._zoom_per_scroll_notch * direction) ** abs(delta_notches)
resulting_zoom = self._zoom * factor
if resulting_zoom < self._zoom_limits[0]:
factor = self._zoom_limits[0] / self._zoom
elif resulting_zoom > self._zoom_limits[1]:
factor = self._zoom_limits[1] / self._zoom
self.scale(factor, factor)
self._zoom *= factor
def save_view(self) -> SavedView:
return SavedView(
self.transform(),
self._default_zoom,
self._zoom,
self.horizontalScrollBar().value(),
self.verticalScrollBar().value(),
)
def restore_view(self, view: SavedView):
self.setTransform(view.transform)
self._default_zoom = view.default_zoom
self._zoom = view.zoom
self.horizontalScrollBar().setValue(view.x)
self.verticalScrollBar().setValue(view.y)
def drawForeground(self, painter: qg.QPainter, rect: qc.QRectF) -> None:
if self._is_loading and self._loading_overlay:
painter.save()
painter.fillRect(rect, qg.QColor("#aa000000"))
painter.setWorldMatrixEnabled(False)
r = self.viewport().rect()
painter.setBrush(qc.Qt.NoBrush)
painter.setPen(qc.Qt.white)
painter.drawText(r.center(), "Loading...")
painter.restore()
class SavedView:
def __init__(
self, transform: qg.QTransform, default_zoom: float, zoom: float, x: int, y: int
):
self.transform = transform
self.default_zoom = default_zoom
self.zoom = zoom
self.x = x
self.y = y
class CADGraphicsViewWithOverlay(CADGraphicsView):
mouse_moved = Signal(qc.QPointF)
element_hovered = Signal(object, int)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._selected_items: list[qw.QGraphicsItem] = []
self._selected_index = None
self._mark_selection = True
@property
def current_hovered_element(self) -> Optional[DXFEntity]:
if self._selected_items:
graphics_item = self._selected_items[self._selected_index]
dxf_entity = graphics_item.data(CorrespondingDXFEntity)
return dxf_entity
else:
return None
def clear(self):
super().clear()
self._selected_items = None
self._selected_index = None
def begin_loading(self):
self.clear()
super().begin_loading()
def drawForeground(self, painter: qg.QPainter, rect: qc.QRectF) -> None:
super().drawForeground(painter, rect)
if self._selected_items and self._mark_selection:
item = self._selected_items[self._selected_index]
r = item.sceneTransform().mapRect(item.boundingRect())
painter.fillRect(r, qg.QColor(0, 255, 0, 100))
def mouseMoveEvent(self, event: qg.QMouseEvent) -> None:
super().mouseMoveEvent(event)
pos = self.mapToScene(event.pos())
self.mouse_moved.emit(pos)
selected_items = self.scene().items(pos)
if selected_items != self._selected_items:
self._selected_items = selected_items
self._selected_index = 0 if self._selected_items else None
self._emit_selected()
def mouseReleaseEvent(self, event: qg.QMouseEvent) -> None:
super().mouseReleaseEvent(event)
if event.button() == qc.Qt.LeftButton and self._selected_items:
self._selected_index = (self._selected_index + 1) % len(
self._selected_items
)
self._emit_selected()
def _emit_selected(self):
self.element_hovered.emit(self._selected_items, self._selected_index)
self.scene().invalidate(self.sceneRect(), qw.QGraphicsScene.ForegroundLayer)
def toggle_selection_marker(self):
self._mark_selection = not self._mark_selection
class CADWidget(qw.QWidget):
def __init__(self, view: CADGraphicsView, config: Configuration = Configuration()):
super().__init__()
layout = qw.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(view)
self.setLayout(layout)
self._view = view
self._view.closing.connect(self.close)
self._config = config
self._bbox_cache = ezdxf.bbox.Cache()
self._doc: Drawing = None # type: ignore
self._render_context: RenderContext = None # type: ignore
self._visible_layers: set[str] = set()
self._current_layout: str = "Model"
self._reset_backend()
def _reset_backend(self):
# clear caches
self._backend = PyQtBackend()
@property
def doc(self) -> Drawing:
return self._doc
@property
def view(self) -> CADGraphicsView:
return self._view
@property
def render_context(self) -> RenderContext:
return self._render_context
@property
def current_layout(self) -> str:
return self._current_layout
def set_document(
self,
document: Drawing,
*,
layout: str = "Model",
draw: bool = True,
):
self._doc = document
# initialize bounding box cache for faste paperspace drawing
self._bbox_cache = ezdxf.bbox.Cache()
self._render_context = self._make_render_context(document)
self._reset_backend()
self._visible_layers = set()
self._current_layout = None
if draw:
self.draw_layout(layout)
def set_visible_layers(self, layers: Set[str]) -> None:
self._visible_layers = layers
self.draw_layout(self._current_layout, reset_view=False)
def _make_render_context(self, doc: Drawing) -> RenderContext:
def update_layers_state(layers: Sequence[LayerProperties]):
if self._visible_layers:
set_layers_state(layers, self._visible_layers, state=True)
render_context = RenderContext(doc)
render_context.set_layer_properties_override(update_layers_state)
return render_context
def draw_layout(
self,
layout_name: str,
reset_view: bool = True,
):
self._current_layout = layout_name
self._view.begin_loading()
new_scene = qw.QGraphicsScene()
self._backend.set_scene(new_scene)
layout = self._doc.layout(layout_name)
self._update_render_context(layout)
try:
self._create_frontend().draw_layout(layout)
finally:
self._backend.finalize()
self._view.end_loading(new_scene)
self._view.buffer_scene_rect()
if reset_view:
self._view.fit_to_scene()
def _create_frontend(self) -> Frontend:
return Frontend(
ctx=self._render_context,
out=self._backend,
config=self._config,
bbox_cache=self._bbox_cache,
)
def _update_render_context(self, layout: Layout) -> None:
assert self._render_context is not None
self._render_context.set_current_layout(layout)
class CADViewer(qw.QMainWindow):
def __init__(self, cad: Optional[CADWidget] = None):
super().__init__()
self._doc: Optional[Drawing] = None
if cad is None:
self._cad = CADWidget(CADGraphicsViewWithOverlay(), config=Configuration())
else:
self._cad = cad
self._view = self._cad.view
if isinstance(self._view, CADGraphicsViewWithOverlay):
self._view.element_hovered.connect(self._on_element_hovered)
self._view.mouse_moved.connect(self._on_mouse_moved)
menu = self.menuBar()
select_doc_action = QAction("Select Document", self)
select_doc_action.triggered.connect(self._select_doc)
menu.addAction(select_doc_action)
self.select_layout_menu = menu.addMenu("Select Layout")
toggle_sidebar_action = QAction("Toggle Sidebar", self)
toggle_sidebar_action.triggered.connect(self._toggle_sidebar)
menu.addAction(toggle_sidebar_action)
toggle_selection_marker_action = QAction("Toggle Entity Marker", self)
toggle_selection_marker_action.triggered.connect(self._toggle_selection_marker)
menu.addAction(toggle_selection_marker_action)
self.reload_menu = menu.addMenu("Reload")
reload_action = QAction("Reload", self)
reload_action.setShortcut(qg.QKeySequence("F5"))
reload_action.triggered.connect(self._reload)
self.reload_menu.addAction(reload_action)
self.keep_view_action = QAction("Keep View", self)
self.keep_view_action.setCheckable(True)
self.keep_view_action.setChecked(True)
self.reload_menu.addAction(self.keep_view_action)
watch_action = QAction("Watch", self)
watch_action.setCheckable(True)
watch_action.toggled.connect(self._toggle_watch)
self.reload_menu.addAction(watch_action)
self._watch_timer = qc.QTimer()
self._watch_timer.setInterval(50)
self._watch_timer.timeout.connect(self._check_watch)
self._watch_mtime = None
self.sidebar = qw.QSplitter(qc.Qt.Vertical)
self.layers = qw.QListWidget()
self.layers.setStyleSheet(
"QListWidget {font-size: 12pt;} "
"QCheckBox {font-size: 12pt; padding-left: 5px;}"
)
self.sidebar.addWidget(self.layers)
info_container = qw.QWidget()
info_layout = qw.QVBoxLayout()
info_layout.setContentsMargins(0, 0, 0, 0)
self.selected_info = qw.QPlainTextEdit()
self.selected_info.setReadOnly(True)
info_layout.addWidget(self.selected_info)
self.mouse_pos = qw.QLabel()
info_layout.addWidget(self.mouse_pos)
info_container.setLayout(info_layout)
self.sidebar.addWidget(info_container)
container = qw.QSplitter()
self.setCentralWidget(container)
container.addWidget(self._cad)
container.addWidget(self.sidebar)
container.setCollapsible(0, False)
container.setCollapsible(1, True)
w = container.width()
container.setSizes([int(3 * w / 4), int(w / 4)])
self.setWindowTitle("CAD Viewer")
self.resize(1600, 900)
self.show()
@staticmethod
def from_config(config: Configuration) -> CADViewer:
return CADViewer(cad=CADWidget(CADGraphicsViewWithOverlay(), config=config))
def _create_cad_widget(self):
self._view = CADGraphicsViewWithOverlay()
self._cad = CADWidget(self._view)
def load_file(self, path: str, layout: str = "Model"):
try:
if os.path.splitext(path)[1].lower() == ".dwg":
doc = odafc.readfile(path)
auditor = doc.audit()
else:
try:
doc = ezdxf.readfile(path)
except ezdxf.DXFError:
doc, auditor = recover.readfile(path)
else:
auditor = doc.audit()
self.set_document(doc, auditor, layout=layout)
except IOError as e:
qw.QMessageBox.critical(self, "Loading Error", str(e))
except DXFStructureError as e:
qw.QMessageBox.critical(
self,
"DXF Structure Error",
f'Invalid DXF file "{path}": {str(e)}',
)
def _select_doc(self):
path, _ = qw.QFileDialog.getOpenFileName(
self,
caption="Select CAD Document",
filter="CAD Documents (*.dxf *.DXF *.dwg *.DWG)",
)
if path:
self.load_file(path)
def set_document(
self,
document: Drawing,
auditor: Auditor,
*,
layout: str = "Model",
draw: bool = True,
):
error_count = len(auditor.errors)
if error_count > 0:
ret = qw.QMessageBox.question(
self,
"Found DXF Errors",
f'Found {error_count} errors in file "{document.filename}"\n'
f"Load file anyway? ",
)
if ret == qw.QMessageBox.No:
auditor.print_error_report(auditor.errors)
return
if document.filename:
try:
self._watch_mtime = os.stat(document.filename).st_mtime
except OSError:
self._watch_mtime = None
else:
self._watch_mtime = None
self._cad.set_document(document, layout=layout, draw=draw)
self._doc = document
self._populate_layouts()
self._populate_layer_list()
self.setWindowTitle("CAD Viewer - " + str(document.filename))
def _populate_layer_list(self):
self.layers.blockSignals(True)
self.layers.clear()
for layer in self._cad.render_context.layers.values():
name = layer.layer
item = qw.QListWidgetItem()
self.layers.addItem(item)
checkbox = qw.QCheckBox(name)
checkbox.setCheckState(
qc.Qt.Checked if layer.is_visible else qc.Qt.Unchecked
)
checkbox.stateChanged.connect(self._layers_updated)
text_color = "#FFFFFF" if is_dark_color(layer.color, 0.4) else "#000000"
checkbox.setStyleSheet(
f"color: {text_color}; background-color: {layer.color}"
)
self.layers.setItemWidget(item, checkbox)
self.layers.blockSignals(False)
def _populate_layouts(self):
def draw_layout(name: str):
def run():
self.draw_layout(name, reset_view=True)
return run
self.select_layout_menu.clear()
for layout_name in self._cad.doc.layout_names_in_taborder():
action = QAction(layout_name, self)
action.triggered.connect(draw_layout(layout_name))
self.select_layout_menu.addAction(action)
def draw_layout(
self,
layout_name: str,
reset_view: bool = True,
):
print(f"drawing {layout_name}")
try:
start = time.perf_counter()
self._cad.draw_layout(layout_name, reset_view=reset_view)
duration = time.perf_counter() - start
print(f"took {duration:.4f} seconds")
except DXFStructureError as e:
qw.QMessageBox.critical(
self,
"DXF Structure Error",
f'Abort rendering of layout "{layout_name}": {str(e)}',
)
def resizeEvent(self, event: qg.QResizeEvent) -> None:
self._view.fit_to_scene()
def _layer_checkboxes(self) -> Iterable[tuple[int, qw.QCheckBox]]:
for i in range(self.layers.count()):
item = self.layers.itemWidget(self.layers.item(i))
yield i, item # type: ignore
@Slot(int) # type: ignore
def _layers_updated(self, item_state: qc.Qt.CheckState):
shift_held = qw.QApplication.keyboardModifiers() & qc.Qt.ShiftModifier
if shift_held:
for i, item in self._layer_checkboxes():
item.blockSignals(True)
item.setCheckState(item_state)
item.blockSignals(False)
visible_layers = set()
for i, layer in self._layer_checkboxes():
if layer.checkState() == qc.Qt.Checked:
visible_layers.add(layer.text())
self._cad.set_visible_layers(visible_layers)
@Slot()
def _toggle_sidebar(self):
self.sidebar.setHidden(not self.sidebar.isHidden())
@Slot()
def _toggle_selection_marker(self):
self._view.toggle_selection_marker()
@Slot()
def _reload(self):
if self._cad.doc is not None and self._cad.doc.filename:
keep_view = self.keep_view_action.isChecked()
view = self._view.save_view() if keep_view else None
self.load_file(self._cad.doc.filename, layout=self._cad.current_layout)
if keep_view:
self._view.restore_view(view)
@Slot()
def _toggle_watch(self):
if self._watch_timer.isActive():
self._watch_timer.stop()
else:
self._watch_timer.start()
@Slot()
def _check_watch(self):
if self._watch_mtime is None or self._cad.doc is None:
return
filename = self._cad.doc.filename
if filename:
try:
mtime = os.stat(filename).st_mtime
except OSError:
return
if mtime != self._watch_mtime:
self._reload()
@Slot(qc.QPointF)
def _on_mouse_moved(self, mouse_pos: qc.QPointF):
self.mouse_pos.setText(
f"mouse position: {mouse_pos.x():.4f}, {mouse_pos.y():.4f}\n"
)
@Slot(object, int)
def _on_element_hovered(self, elements: list[qw.QGraphicsItem], index: int):
if not elements:
text = "No element selected"
else:
text = f"Selected: {index + 1} / {len(elements)} (click to cycle)\n"
element = elements[index]
dxf_entity: DXFGraphic | str | None = element.data(CorrespondingDXFEntity)
if isinstance(dxf_entity, str):
dxf_entity = self.load_dxf_entity(dxf_entity)
if dxf_entity is None:
text += "No data"
else:
text += (
f"Selected Entity: {dxf_entity}\n"
f"Layer: {dxf_entity.dxf.layer}\n\nDXF Attributes:\n"
)
text += _entity_attribs_string(dxf_entity)
dxf_parent_stack = element.data(CorrespondingDXFParentStack)
if dxf_parent_stack:
text += "\nParents:\n"
for entity in reversed(dxf_parent_stack):
text += f"- {entity}\n"
text += _entity_attribs_string(entity, indent=" ")
self.selected_info.setPlainText(text)
def load_dxf_entity(self, entity_handle: str) -> DXFGraphic | None:
if self._doc is not None:
return self._doc.entitydb.get(entity_handle)
return None
def _entity_attribs_string(dxf_entity: DXFGraphic, indent: str = "") -> str:
text = ""
for key, value in dxf_entity.dxf.all_existing_dxf_attribs().items():
text += f"{indent}- {key}: {value}\n"
return text
@@ -0,0 +1,447 @@
# Copyright (c) 2023-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Iterable,
Iterator,
Sequence,
Callable,
Optional,
NamedTuple,
)
from typing_extensions import Self, TypeAlias
import copy
import abc
from ezdxf.math import BoundingBox2d, Matrix44, Vec2, UVec
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d, EmptyShapeError
from ezdxf.tools import take2
from ezdxf.tools.clipping_portal import ClippingRect
from .backend import BackendInterface, ImageData
from .config import Configuration
from .properties import BackendProperties
from .type_hints import Color
class DataRecord(abc.ABC):
def __init__(self) -> None:
self.property_hash: int = 0
self.handle: str = ""
@abc.abstractmethod
def bbox(self) -> BoundingBox2d:
...
@abc.abstractmethod
def transform_inplace(self, m: Matrix44) -> None:
...
class PointsRecord(DataRecord):
# n=1 point; n=2 line; n>2 filled polygon
def __init__(self, points: NumpyPoints2d) -> None:
super().__init__()
self.points: NumpyPoints2d = points
def bbox(self) -> BoundingBox2d:
try:
return self.points.bbox()
except EmptyShapeError:
pass
return BoundingBox2d()
def transform_inplace(self, m: Matrix44) -> None:
self.points.transform_inplace(m)
class SolidLinesRecord(DataRecord):
def __init__(self, lines: NumpyPoints2d) -> None:
super().__init__()
self.lines: NumpyPoints2d = lines
def bbox(self) -> BoundingBox2d:
try:
return self.lines.bbox()
except EmptyShapeError:
pass
return BoundingBox2d()
def transform_inplace(self, m: Matrix44) -> None:
self.lines.transform_inplace(m)
class PathRecord(DataRecord):
def __init__(self, path: NumpyPath2d) -> None:
super().__init__()
self.path: NumpyPath2d = path
def bbox(self) -> BoundingBox2d:
try:
return self.path.bbox()
except EmptyShapeError:
pass
return BoundingBox2d()
def transform_inplace(self, m: Matrix44) -> None:
self.path.transform_inplace(m)
class FilledPathsRecord(DataRecord):
def __init__(self, paths: Sequence[NumpyPath2d]) -> None:
super().__init__()
self.paths: Sequence[NumpyPath2d] = paths
def bbox(self) -> BoundingBox2d:
bbox = BoundingBox2d()
for path in self.paths:
if len(path):
bbox.extend(path.extents())
return bbox
def transform_inplace(self, m: Matrix44) -> None:
for path in self.paths:
path.transform_inplace(m)
class ImageRecord(DataRecord):
def __init__(self, boundary: NumpyPoints2d, image_data: ImageData) -> None:
super().__init__()
self.boundary: NumpyPoints2d = boundary
self.image_data: ImageData = image_data
def bbox(self) -> BoundingBox2d:
try:
return self.boundary.bbox()
except EmptyShapeError:
pass
return BoundingBox2d()
def transform_inplace(self, m: Matrix44) -> None:
self.boundary.transform_inplace(m)
self.image_data.transform @= m
class Recorder(BackendInterface):
"""Records the output of the Frontend class."""
def __init__(self) -> None:
self.config = Configuration()
self.background: Color = "#000000"
self.records: list[DataRecord] = []
self.properties: dict[int, BackendProperties] = dict()
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()
"""
player = Player()
player.config = self.config
player.background = self.background
player.records = self.records
player.properties = self.properties
player.has_shared_recordings = True
return player
def configure(self, config: Configuration) -> None:
self.config = config
def set_background(self, color: Color) -> None:
self.background = color
def store(self, record: DataRecord, properties: BackendProperties) -> None:
# exclude top-level entity handle to reduce the variance:
# color, lineweight, layer, pen
prop_hash = hash(properties[:4])
record.property_hash = prop_hash
record.handle = properties.handle
self.records.append(record)
self.properties[prop_hash] = properties
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.store(PointsRecord(NumpyPoints2d((pos,))), properties)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.store(PointsRecord(NumpyPoints2d((start, end))), properties)
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
def flatten() -> Iterator[Vec2]:
for s, e in lines:
yield s
yield e
self.store(SolidLinesRecord(NumpyPoints2d(flatten())), properties)
def draw_path(self, path: NumpyPath2d, properties: BackendProperties) -> None:
assert isinstance(path, NumpyPath2d)
self.store(PathRecord(path), properties)
def draw_filled_polygon(
self, points: NumpyPoints2d, properties: BackendProperties
) -> None:
assert isinstance(points, NumpyPoints2d)
self.store(PointsRecord(points), properties)
def draw_filled_paths(
self, paths: Iterable[NumpyPath2d], properties: BackendProperties
) -> None:
paths = tuple(paths)
if len(paths) == 0:
return
assert isinstance(paths[0], NumpyPath2d)
self.store(FilledPathsRecord(paths), properties)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
# preserve the boundary in image_data in pixel coordinates
boundary = copy.deepcopy(image_data.pixel_boundary_path)
boundary.transform_inplace(image_data.transform)
self.store(ImageRecord(boundary, image_data), properties)
def enter_entity(self, entity, properties) -> None:
pass
def exit_entity(self, entity) -> None:
pass
def clear(self) -> None:
raise NotImplementedError()
def finalize(self) -> None:
pass
class Override(NamedTuple):
"""Represents the override state for a data record.
Attributes:
properties: original or modified :class:`BackendProperties`
is_visible: override visibility e.g. switch layers on/off
"""
properties: BackendProperties
is_visible: bool = True
OverrideFunc: TypeAlias = Callable[[BackendProperties], Override]
class Player:
"""Plays the recordings of the :class:`Recorder` backend on another backend."""
def __init__(self) -> None:
self.config = Configuration()
self.background: Color = "#000000"
self.records: list[DataRecord] = []
self.properties: dict[int, BackendProperties] = dict()
self._bbox = BoundingBox2d()
self.has_shared_recordings: bool = False
def __copy__(self) -> Self:
"""Returns a copy of the player with non-shared recordings."""
player = self.__class__()
# config is a frozen dataclass:
player.config = self.config
player.background = self.background
# recordings are mutable: transform and crop inplace
player.records = copy.deepcopy(self.records)
# the properties dict may grow, but entries will never be removed:
player.properties = self.properties
player.has_shared_recordings = False
return player
copy = __copy__
def recordings(self) -> Iterator[tuple[DataRecord, BackendProperties]]:
"""Yields all recordings as `(DataRecord, BackendProperties)` tuples."""
props = self.properties
for record in self.records:
properties = BackendProperties(
*props[record.property_hash][:4], record.handle
)
yield record, properties
def replay(
self, backend: BackendInterface, override: Optional[OverrideFunc] = None
) -> None:
"""Replay the recording on another backend that implements the
:class:`BackendInterface`. The optional `override` function can be used to
override the properties and state of data records, it gets the :class:`BackendProperties`
as input and must return an :class:`Override` instance.
"""
backend.configure(self.config)
backend.set_background(self.background)
for record, properties in self.recordings():
if override:
state = override(properties)
if not state.is_visible:
continue
properties = state.properties
if isinstance(record, PointsRecord):
count = len(record.points)
if count == 0:
continue
if count > 2:
backend.draw_filled_polygon(record.points, properties)
continue
vertices = record.points.vertices()
if len(vertices) == 1:
backend.draw_point(vertices[0], properties)
else:
backend.draw_line(vertices[0], vertices[1], properties)
elif isinstance(record, SolidLinesRecord):
backend.draw_solid_lines(take2(record.lines.vertices()), properties)
elif isinstance(record, PathRecord):
backend.draw_path(record.path, properties)
elif isinstance(record, FilledPathsRecord):
backend.draw_filled_paths(record.paths, properties)
elif isinstance(record, ImageRecord):
backend.draw_image(record.image_data, properties)
backend.finalize()
def transform(self, m: Matrix44) -> None:
"""Transforms the recordings inplace by a transformation matrix `m` of type
:class:`~ezdxf.math.Matrix44`.
"""
for record in self.records:
record.transform_inplace(m)
if self._bbox.has_data:
# works for 90-, 180- and 270-degree rotation
self._bbox = BoundingBox2d(m.fast_2d_transform(self._bbox.rect_vertices()))
def bbox(self) -> BoundingBox2d:
"""Returns the bounding box of all records as :class:`~ezdxf.math.BoundingBox2d`."""
if not self._bbox.has_data:
self.update_bbox()
return self._bbox
def update_bbox(self) -> None:
bbox = BoundingBox2d()
for record in self.records:
bbox.extend(record.bbox())
self._bbox = bbox
def crop_rect(self, p1: UVec, p2: UVec, distance: float) -> None:
"""Crop recorded shapes inplace by a rectangle defined by two points.
The argument `distance` defines the approximation precision for paths which have
to be approximated as polylines for cropping but only paths which are really get
cropped are approximated, paths that are fully inside the crop box will not be
approximated.
Args:
p1: first corner of the clipping rectangle
p2: second corner of the clipping rectangle
distance: maximum distance from the center of the curve to the
center of the line segment between two approximation points to
determine if a segment should be subdivided.
"""
crop_rect = BoundingBox2d([Vec2(p1), Vec2(p2)])
self.records = crop_records_rect(self.records, crop_rect, distance)
self._bbox = BoundingBox2d() # determine new bounding box on demand
def crop_records_rect(
records: list[DataRecord], crop_rect: BoundingBox2d, distance: float
) -> list[DataRecord]:
"""Crop recorded shapes inplace by a rectangle."""
def sort_paths(np_paths: Sequence[NumpyPath2d]):
_inside: list[NumpyPath2d] = []
_crop: list[NumpyPath2d] = []
for np_path in np_paths:
bbox = BoundingBox2d(np_path.extents())
if not crop_rect.has_intersection(bbox):
# path is complete outside the cropping rectangle
pass
elif crop_rect.inside(bbox.extmin) and crop_rect.inside(bbox.extmax):
# path is complete inside the cropping rectangle
_inside.append(np_path)
else:
_crop.append(np_path)
return _crop, _inside
def crop_paths(
np_paths: Sequence[NumpyPath2d],
) -> list[NumpyPath2d]:
return list(clipper.clip_filled_paths(np_paths, distance))
# an undefined crop box crops nothing:
if not crop_rect.has_data:
return records
cropped_records: list[DataRecord] = []
size = crop_rect.size
# a crop box size of zero in any dimension crops everything:
if size.x < 1e-12 or size.y < 1e-12:
return cropped_records
clipper = ClippingRect(crop_rect.rect_vertices())
for record in records:
record_box = record.bbox()
if not crop_rect.has_intersection(record_box):
# record is complete outside the cropping rectangle
continue
if crop_rect.inside(record_box.extmin) and crop_rect.inside(record_box.extmax):
# record is complete inside the cropping rectangle
cropped_records.append(record)
continue
if isinstance(record, FilledPathsRecord):
paths_to_crop, inside = sort_paths(record.paths)
cropped_paths = crop_paths(paths_to_crop) + inside
if cropped_paths:
record.paths = tuple(cropped_paths)
cropped_records.append(record)
elif isinstance(record, PathRecord):
# could be split into multiple parts
for p in clipper.clip_paths([record.path], distance):
path_record = PathRecord(p)
path_record.property_hash = record.property_hash
path_record.handle = record.handle
cropped_records.append(path_record)
elif isinstance(record, PointsRecord):
count = len(record.points)
if count == 1:
# record is inside the clipping shape!
cropped_records.append(record)
elif count == 2:
s, e = record.points.vertices()
for segment in clipper.clip_line(s, e):
if not segment:
continue
_record = copy.copy(record) # shallow copy
_record.points = NumpyPoints2d(segment)
cropped_records.append(_record)
else:
for polygon in clipper.clip_polygon(record.points):
if not polygon:
continue
_record = copy.copy(record) # shallow copy!
_record.points = polygon
cropped_records.append(_record)
elif isinstance(record, SolidLinesRecord):
points: list[Vec2] = []
for s, e in take2(record.lines.vertices()):
for segment in clipper.clip_line(s, e):
points.extend(segment)
record.lines = NumpyPoints2d(points)
cropped_records.append(record)
elif isinstance(record, ImageRecord):
pass
# TODO: Image cropping not supported
# Crop image boundary and apply transparency to cropped
# parts of the image? -> Image boundary is now a polygon!
else:
raise ValueError("invalid record type")
return cropped_records
@@ -0,0 +1,422 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Sequence, no_type_check
import copy
from xml.etree import ElementTree as ET
import numpy as np
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
from ezdxf.path import Command
from .type_hints import Color
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import Configuration, LineweightPolicy
from .properties import BackendProperties
from . import layout, recorder
__all__ = ["SVGBackend"]
class SVGBackend(recorder.Recorder):
"""This is a native SVG rendering backend and does not require any external packages
to render SVG images other than the core dependencies. This backend support content
cropping at page margins.
"""
def __init__(self) -> None:
super().__init__()
self._init_flip_y = True
def get_xml_root_element(
self,
page: layout.Page,
*,
settings: layout.Settings = layout.Settings(),
render_box: BoundingBox2d | None = None,
) -> ET.Element:
top_origin = True
settings = copy.copy(settings)
# DXF coordinates are mapped to integer viewBox coordinates in the first
# quadrant, producing compact SVG files. The larger the coordinate range, the
# more precise and the lager the files.
settings.output_coordinate_space = 1_000_000
# This player changes the original recordings!
player = self.player()
if render_box is None:
render_box = player.bbox()
# the page origin (0, 0) is in the top-left corner.
output_layout = layout.Layout(render_box, flip_y=self._init_flip_y)
page = output_layout.get_final_page(page, settings)
if page.width == 0 or page.height == 0:
return ET.Element("svg") # empty page
m = output_layout.get_placement_matrix(
page, settings=settings, top_origin=top_origin
)
# transform content to the output coordinates space:
player.transform(m)
if settings.crop_at_margins:
p1, p2 = page.get_margin_rect(top_origin=top_origin) # in mm
# scale factor to map page coordinates to output space coordinates:
output_scale = settings.page_output_scale_factor(page)
max_sagitta = 0.1 * output_scale # curve approximation 0.1 mm
# crop content inplace by the margin rect:
player.crop_rect(p1 * output_scale, p2 * output_scale, max_sagitta)
self._init_flip_y = False
backend = self.make_backend(page, settings)
player.replay(backend)
return backend.get_xml_root_element()
def get_string(
self,
page: layout.Page,
*,
settings: layout.Settings = layout.Settings(),
render_box: BoundingBox2d | None = None,
xml_declaration=True,
) -> str:
"""Returns the XML data as unicode string.
Args:
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
render_box: set explicit region to render, default is content bounding box
xml_declaration: inserts the "<?xml version='1.0' encoding='utf-8'?>" string
in front of the <svg> element
"""
xml = self.get_xml_root_element(page, settings=settings, render_box=render_box)
return ET.tostring(xml, encoding="unicode", xml_declaration=xml_declaration)
@staticmethod
def make_backend(page: layout.Page, settings: layout.Settings) -> SVGRenderBackend:
"""Override this method to use a customized render backend."""
return SVGRenderBackend(page, settings)
def make_view_box(page: layout.Page, output_coordinate_space: float) -> tuple[int, int]:
size = round(output_coordinate_space)
if page.width > page.height:
return size, round(size * (page.height / page.width))
return round(size * (page.width / page.height)), size
def scale_page_to_view_box(page: layout.Page, output_coordinate_space: float) -> float:
# The viewBox coordinates are integer values in the range of [0, output_coordinate_space]
return min(
output_coordinate_space / page.width,
output_coordinate_space / page.height,
)
class Styles:
def __init__(self, xml: ET.Element) -> None:
self._xml = xml
self._class_names: dict[int, str] = dict()
self._counter = 1
def get_class(
self,
*,
stroke: Color = "none",
stroke_width: int | str = "none",
stroke_opacity: float = 1.0,
fill: Color = "none",
fill_opacity: float = 1.0,
) -> str:
style = (
f"{{stroke: {stroke}; "
f"stroke-width: {stroke_width}; "
f"stroke-opacity: {stroke_opacity:.3f}; "
f"fill: {fill}; "
f"fill-opacity: {fill_opacity:.3f};}}"
)
key = hash(style)
try:
return self._class_names[key]
except KeyError:
pass
name = f"C{self._counter:X}"
self._counter += 1
self._add_class(name, style)
self._class_names[key] = name
return name
def _add_class(self, name, style_str: str) -> None:
style = ET.Element("style")
style.text = f".{name} {style_str}"
self._xml.append(style)
CMD_M_ABS = "M {0.x:.0f} {0.y:.0f}"
CMD_M_REL = "m {0.x:.0f} {0.y:.0f}"
CMD_L_ABS = "L {0.x:.0f} {0.y:.0f}"
CMD_L_REL = "l {0.x:.0f} {0.y:.0f}"
CMD_C3_ABS = "Q {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f}"
CMD_C3_REL = "q {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f}"
CMD_C4_ABS = "C {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f} {2.x:.0f} {2.y:.0f}"
CMD_C4_REL = "c {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f} {2.x:.0f} {2.y:.0f}"
CMD_CONT = "{0.x:.0f} {0.y:.0f}"
class SVGRenderBackend(BackendInterface):
"""Creates the SVG output.
This backend requires some preliminary work, record the frontend output via the
Recorder backend to accomplish the following requirements:
- Scale the content in y-axis by -1 to invert the y-axis (SVG).
- Move content in the first quadrant of the coordinate system.
- The viewBox is defined by the lower left corner in the origin (0, 0) and
the upper right corner at (view_box_width, view_box_height)
- The output coordinates are integer values, scale the content appropriately.
- Replay the recorded output on this backend.
"""
def __init__(self, page: layout.Page, settings: layout.Settings) -> None:
self.settings = settings
self._stroke_width_cache: dict[float, int] = dict()
view_box_width, view_box_height = make_view_box(
page, settings.output_coordinate_space
)
# StrokeWidthPolicy.absolute:
# stroke-width in mm as resolved by the frontend
self.stroke_width_scale: float = view_box_width / page.width_in_mm
self.min_lineweight = 0.05 # in mm, set by configure()
self.lineweight_scaling = 1.0 # set by configure()
self.lineweight_policy = LineweightPolicy.ABSOLUTE # set by configure()
# fixed lineweight for all strokes in ABSOLUTE mode:
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
# set Configuration.lineweight_scaling to 0
# LineweightPolicy.RELATIVE:
# max_stroke_width is determined as a certain percentage of settings.output_coordinate_space
self.max_stroke_width: int = int(
settings.output_coordinate_space * settings.max_stroke_width
)
# min_stroke_width is determined as a certain percentage of max_stroke_width
self.min_stroke_width: int = int(
self.max_stroke_width * settings.min_stroke_width
)
# LineweightPolicy.RELATIVE_FIXED:
# all strokes have a fixed stroke-width as a certain percentage of max_stroke_width
self.fixed_stroke_width: int = int(
self.max_stroke_width * settings.fixed_stroke_width
)
self.root = ET.Element(
"svg",
xmlns="http://www.w3.org/2000/svg",
width=f"{page.width_in_mm:g}mm",
height=f"{page.height_in_mm:g}mm",
viewBox=f"0 0 {view_box_width} {view_box_height}",
)
self.styles = Styles(ET.SubElement(self.root, "defs"))
self.background = ET.SubElement(
self.root,
"rect",
fill="white",
x="0",
y="0",
width=str(view_box_width),
height=str(view_box_height),
)
self.entities = ET.SubElement(self.root, "g")
self.entities.set("stroke-linecap", "round")
self.entities.set("stroke-linejoin", "round")
self.entities.set("fill-rule", "evenodd")
def get_xml_root_element(self) -> ET.Element:
return self.root
def add_strokes(self, d: str, properties: BackendProperties):
if not d:
return
element = ET.SubElement(self.entities, "path", d=d)
stroke_width = self.resolve_stroke_width(properties.lineweight)
stroke_color, stroke_opacity = self.resolve_color(properties.color)
cls = self.styles.get_class(
stroke=stroke_color,
stroke_width=stroke_width,
stroke_opacity=stroke_opacity,
)
element.set("class", cls)
def add_filling(self, d: str, properties: BackendProperties):
if not d:
return
element = ET.SubElement(self.entities, "path", d=d)
fill_color, fill_opacity = self.resolve_color(properties.color)
cls = self.styles.get_class(fill=fill_color, fill_opacity=fill_opacity)
element.set("class", cls)
def resolve_color(self, color: Color) -> tuple[Color, float]:
return color[:7], alpha_to_opacity(color[7:9])
def resolve_stroke_width(self, width: float) -> int:
try:
return self._stroke_width_cache[width]
except KeyError:
pass
stroke_width = self.fixed_stroke_width
policy = self.lineweight_policy
if policy == LineweightPolicy.ABSOLUTE:
if self.lineweight_scaling:
width = max(self.min_lineweight, width) * self.lineweight_scaling
else:
width = self.min_lineweight
stroke_width = round(width * self.stroke_width_scale)
elif policy == LineweightPolicy.RELATIVE:
stroke_width = map_lineweight_to_stroke_width(
width, self.min_stroke_width, self.max_stroke_width
)
self._stroke_width_cache[width] = stroke_width
return stroke_width
def set_background(self, color: Color) -> None:
color_str = color[:7]
opacity = alpha_to_opacity(color[7:9])
self.background.set("fill", color_str)
self.background.set("fill-opacity", str(opacity))
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.add_strokes(self.make_polyline_str([pos, pos]), properties)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.add_strokes(self.make_polyline_str([start, end]), properties)
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
lines = list(lines)
if len(lines) == 0:
return
self.add_strokes(self.make_multi_line_str(lines), properties)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
self.add_strokes(self.make_path_str(path), properties)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
d = []
for path in paths:
if len(path):
d.append(self.make_path_str(path, close=True))
self.add_filling(" ".join(d), properties)
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
self.add_filling(
self.make_polyline_str(points.vertices(), close=True), properties
)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
pass # TODO: not implemented
@staticmethod
def make_polyline_str(points: Sequence[Vec2], close=False) -> str:
if len(points) < 2:
return ""
current = points[0]
# first move is absolute, consecutive lines are relative:
d: list[str] = [CMD_M_ABS.format(current), "l"]
for point in points[1:]:
relative = point - current
current = point
d.append(CMD_CONT.format(relative))
if close:
d.append("Z")
return " ".join(d)
@staticmethod
def make_multi_line_str(lines: Sequence[tuple[Vec2, Vec2]]) -> str:
assert len(lines) > 0
start, end = lines[0]
d: list[str] = [CMD_M_ABS.format(start), CMD_L_REL.format(end - start)]
current = end
for start, end in lines[1:]:
d.append(CMD_M_REL.format(start - current))
current = start
d.append(CMD_L_REL.format(end - current))
current = end
return " ".join(d)
@staticmethod
@no_type_check
def make_path_str(path: BkPath2d, close=False) -> str:
d: list[str] = [CMD_M_ABS.format(path.start)]
if len(path) == 0:
return ""
current = path.start
for cmd in path.commands():
end = cmd.end
if cmd.type == Command.MOVE_TO:
d.append(CMD_M_REL.format(end - current))
elif cmd.type == Command.LINE_TO:
d.append(CMD_L_REL.format(end - current))
elif cmd.type == Command.CURVE3_TO:
d.append(CMD_C3_REL.format(cmd.ctrl - current, end - current))
elif cmd.type == Command.CURVE4_TO:
d.append(
CMD_C4_REL.format(
cmd.ctrl1 - current, cmd.ctrl2 - current, end - current
)
)
current = end
if close:
d.append("Z")
return " ".join(d)
def configure(self, config: Configuration) -> None:
self.lineweight_policy = config.lineweight_policy
if config.min_lineweight:
# config.min_lineweight in 1/300 inch!
min_lineweight_mm = config.min_lineweight * 25.4 / 300
self.min_lineweight = max(0.05, min_lineweight_mm)
self.lineweight_scaling = config.lineweight_scaling
def clear(self) -> None:
pass
def finalize(self) -> None:
pass
def enter_entity(self, entity, properties) -> None:
pass
def exit_entity(self, entity) -> None:
pass
def alpha_to_opacity(alpha: str) -> float:
# stroke-opacity: 0.0 = transparent; 1.0 = opaque
# alpha: "00" = transparent; "ff" = opaque
if len(alpha):
try:
return int(alpha, 16) / 255
except ValueError:
pass
return 1.0
def map_lineweight_to_stroke_width(
lineweight: float,
min_stroke_width: int,
max_stroke_width: int,
min_lineweight=0.05, # defined by DXF
max_lineweight=2.11, # defined by DXF
) -> int:
"""Map the DXF lineweight in mm to stroke-width in viewBox coordinates."""
lineweight = max(min(lineweight, max_lineweight), min_lineweight) - min_lineweight
factor = (max_stroke_width - min_stroke_width) / (max_lineweight - min_lineweight)
return min_stroke_width + round(lineweight * factor)
@@ -0,0 +1,351 @@
# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
from __future__ import annotations
from typing import Union, Tuple, Iterable, Optional, Callable
from typing_extensions import TypeAlias
import enum
from math import radians
import ezdxf.lldxf.const as DXFConstants
from ezdxf.enums import (
TextEntityAlignment,
MAP_TEXT_ENUM_TO_ALIGN_FLAGS,
MTextEntityAlignment,
)
from ezdxf.entities import MText, Text, Attrib, AttDef
from ezdxf.math import Matrix44, Vec3, sign
from ezdxf.fonts import fonts
from ezdxf.fonts.font_measurements import FontMeasurements
from ezdxf.tools.text import plain_text, text_wrap
from .text_renderer import TextRenderer
"""
Search google for 'typography' or 'font anatomy' for explanations of terms like
'baseline' and 'x-height'
A Visual Guide to the Anatomy of Typography: https://visme.co/blog/type-anatomy/
Anatomy of a Character: https://www.fonts.com/content/learning/fontology/level-1/type-anatomy/anatomy
"""
@enum.unique
class HAlignment(enum.Enum):
LEFT = 0
CENTER = 1
RIGHT = 2
@enum.unique
class VAlignment(enum.Enum):
TOP = 0 # the top of capital letters or letters with ascenders (like 'b')
LOWER_CASE_CENTER = 1 # the midpoint between the baseline and the x-height
BASELINE = 2 # the line which text rests on, characters with descenders (like 'p') are partially below this line
BOTTOM = 3 # the lowest point on a character with a descender (like 'p')
UPPER_CASE_CENTER = 4 # the midpoint between the baseline and the cap-height
Alignment: TypeAlias = Tuple[HAlignment, VAlignment]
AnyText: TypeAlias = Union[Text, MText, Attrib, AttDef]
# multiple of cap_height between the baseline of the previous line and the
# baseline of the next line
DEFAULT_LINE_SPACING = 5 / 3
DXF_TEXT_ALIGNMENT_TO_ALIGNMENT: dict[TextEntityAlignment, Alignment] = {
TextEntityAlignment.LEFT: (HAlignment.LEFT, VAlignment.BASELINE),
TextEntityAlignment.CENTER: (HAlignment.CENTER, VAlignment.BASELINE),
TextEntityAlignment.RIGHT: (HAlignment.RIGHT, VAlignment.BASELINE),
TextEntityAlignment.ALIGNED: (HAlignment.CENTER, VAlignment.BASELINE),
TextEntityAlignment.MIDDLE: (
HAlignment.CENTER,
VAlignment.LOWER_CASE_CENTER,
),
TextEntityAlignment.FIT: (HAlignment.CENTER, VAlignment.BASELINE),
TextEntityAlignment.BOTTOM_LEFT: (HAlignment.LEFT, VAlignment.BOTTOM),
TextEntityAlignment.BOTTOM_CENTER: (HAlignment.CENTER, VAlignment.BOTTOM),
TextEntityAlignment.BOTTOM_RIGHT: (HAlignment.RIGHT, VAlignment.BOTTOM),
TextEntityAlignment.MIDDLE_LEFT: (
HAlignment.LEFT,
VAlignment.UPPER_CASE_CENTER,
),
TextEntityAlignment.MIDDLE_CENTER: (
HAlignment.CENTER,
VAlignment.UPPER_CASE_CENTER,
),
TextEntityAlignment.MIDDLE_RIGHT: (
HAlignment.RIGHT,
VAlignment.UPPER_CASE_CENTER,
),
TextEntityAlignment.TOP_LEFT: (HAlignment.LEFT, VAlignment.TOP),
TextEntityAlignment.TOP_CENTER: (HAlignment.CENTER, VAlignment.TOP),
TextEntityAlignment.TOP_RIGHT: (HAlignment.RIGHT, VAlignment.TOP),
}
assert DXF_TEXT_ALIGNMENT_TO_ALIGNMENT.keys() == MAP_TEXT_ENUM_TO_ALIGN_FLAGS.keys()
DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT: dict[int, Alignment] = {
DXFConstants.MTEXT_TOP_LEFT: (HAlignment.LEFT, VAlignment.TOP),
DXFConstants.MTEXT_TOP_CENTER: (HAlignment.CENTER, VAlignment.TOP),
DXFConstants.MTEXT_TOP_RIGHT: (HAlignment.RIGHT, VAlignment.TOP),
DXFConstants.MTEXT_MIDDLE_LEFT: (
HAlignment.LEFT,
VAlignment.LOWER_CASE_CENTER,
),
DXFConstants.MTEXT_MIDDLE_CENTER: (
HAlignment.CENTER,
VAlignment.LOWER_CASE_CENTER,
),
DXFConstants.MTEXT_MIDDLE_RIGHT: (
HAlignment.RIGHT,
VAlignment.LOWER_CASE_CENTER,
),
DXFConstants.MTEXT_BOTTOM_LEFT: (HAlignment.LEFT, VAlignment.BOTTOM),
DXFConstants.MTEXT_BOTTOM_CENTER: (HAlignment.CENTER, VAlignment.BOTTOM),
DXFConstants.MTEXT_BOTTOM_RIGHT: (HAlignment.RIGHT, VAlignment.BOTTOM),
}
assert len(DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT) == len(MTextEntityAlignment)
def _calc_aligned_rotation(text: Text) -> float:
p1: Vec3 = text.dxf.insert
p2: Vec3 = text.dxf.align_point
if not p1.isclose(p2):
return (p2 - p1).angle
else:
return radians(text.dxf.rotation)
def _get_rotation(text: AnyText) -> Matrix44:
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
if text.get_align_enum() in (
TextEntityAlignment.FIT,
TextEntityAlignment.ALIGNED,
):
rotation = _calc_aligned_rotation(text)
else:
rotation = radians(text.dxf.rotation)
return Matrix44.axis_rotate(text.dxf.extrusion, rotation)
elif isinstance(text, MText):
return Matrix44.axis_rotate(Vec3(0, 0, 1), radians(text.get_rotation()))
else:
raise TypeError(type(text))
def _get_alignment(text: AnyText) -> Alignment:
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
return DXF_TEXT_ALIGNMENT_TO_ALIGNMENT[text.get_align_enum()]
elif isinstance(text, MText):
return DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT[text.dxf.attachment_point]
else:
raise TypeError(type(text))
def _get_cap_height(text: AnyText) -> float:
if isinstance(text, (Text, Attrib, AttDef)):
return text.dxf.height
elif isinstance(text, MText):
return text.dxf.char_height
else:
raise TypeError(type(text))
def _get_line_spacing(text: AnyText, cap_height: float) -> float:
if isinstance(text, (Attrib, AttDef, Text)):
return 0.0
elif isinstance(text, MText):
return cap_height * DEFAULT_LINE_SPACING * text.dxf.line_spacing_factor
else:
raise TypeError(type(text))
def _split_into_lines(
entity: AnyText,
box_width: Optional[float],
get_text_width: Callable[[str], float],
) -> list[str]:
if isinstance(entity, AttDef):
# ATTDEF outside of an Insert renders the tag rather than the value
text = plain_text(entity.dxf.tag)
else:
text = entity.plain_text() # type: ignore
if isinstance(entity, (Text, Attrib, AttDef)):
assert "\n" not in text
return [text]
else:
return text_wrap(text, box_width, get_text_width)
def _get_text_width(text: AnyText) -> Optional[float]:
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
return None
elif isinstance(text, MText):
width = text.dxf.width
return None if width == 0.0 else width
else:
raise TypeError(type(text))
def _get_extra_transform(text: AnyText, line_width: float) -> Matrix44:
extra_transform = Matrix44()
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
# 'width' is the width *scale factor* so 1.0 by default:
scale_x = text.dxf.width
scale_y = 1.0
# Calculate text stretching for FIT and ALIGNED:
alignment = text.get_align_enum()
line_width = abs(line_width)
if (
alignment in (TextEntityAlignment.FIT, TextEntityAlignment.ALIGNED)
and line_width > 1e-9
):
defined_length = (text.dxf.align_point - text.dxf.insert).magnitude
stretch_factor = defined_length / line_width
scale_x = stretch_factor
if alignment == TextEntityAlignment.ALIGNED:
scale_y = stretch_factor
if text.dxf.text_generation_flag & DXFConstants.MIRROR_X:
scale_x *= -1.0
if text.dxf.text_generation_flag & DXFConstants.MIRROR_Y:
scale_y *= -1.0
# Magnitude of extrusion does not have any effect.
# An extrusion of (0, 0, 0) acts like (0, 0, 1)
scale_x *= sign(text.dxf.extrusion.z)
if scale_x != 1.0 or scale_y != 1.0:
extra_transform = Matrix44.scale(scale_x, scale_y)
elif isinstance(text, MText):
# Not sure about the rationale behind this but it does match AutoCAD
# behavior...
scale_y = sign(text.dxf.extrusion.z)
if scale_y != 1.0:
extra_transform = Matrix44.scale(1.0, scale_y)
return extra_transform
def _apply_alignment(
alignment: Alignment,
line_widths: list[float],
line_spacing: float,
box_width: Optional[float],
font_measurements: FontMeasurements,
) -> tuple[tuple[float, float], list[float], list[float]]:
if not line_widths:
return (0, 0), [], []
halign, valign = alignment
line_ys = [
-font_measurements.baseline - (font_measurements.cap_height + i * line_spacing)
for i in range(len(line_widths))
]
if box_width is None:
box_width = max(line_widths)
last_baseline = line_ys[-1]
if halign == HAlignment.LEFT:
anchor_x = 0.0
line_xs = [0.0] * len(line_widths)
elif halign == HAlignment.CENTER:
anchor_x = box_width / 2
line_xs = [anchor_x - w / 2 for w in line_widths]
elif halign == HAlignment.RIGHT:
anchor_x = box_width
line_xs = [anchor_x - w for w in line_widths]
else:
raise ValueError(halign)
if valign == VAlignment.TOP:
anchor_y = 0.0
elif valign == VAlignment.LOWER_CASE_CENTER:
first_line_lower_case_top = line_ys[0] + font_measurements.x_height
anchor_y = (first_line_lower_case_top + last_baseline) / 2
elif valign == VAlignment.UPPER_CASE_CENTER:
first_line_upper_case_top = line_ys[0] + font_measurements.cap_height
anchor_y = (first_line_upper_case_top + last_baseline) / 2
elif valign == VAlignment.BASELINE:
anchor_y = last_baseline
elif valign == VAlignment.BOTTOM:
anchor_y = last_baseline - font_measurements.descender_height
else:
raise ValueError(valign)
return (anchor_x, anchor_y), line_xs, line_ys
def _get_wcs_insert(text: AnyText) -> Vec3:
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
insert: Vec3 = text.dxf.insert
align_point: Vec3 = text.dxf.align_point
alignment: TextEntityAlignment = text.get_align_enum()
if alignment == TextEntityAlignment.LEFT:
# LEFT/BASELINE is always located at the insert point.
pass
elif alignment in (
TextEntityAlignment.FIT,
TextEntityAlignment.ALIGNED,
):
# Interpolate insertion location between insert and align point:
insert = insert.lerp(align_point, factor=0.5)
else:
# Everything else is located at the align point:
insert = align_point
return text.ocs().to_wcs(insert)
else:
return text.dxf.insert
# Simple but fast MTEXT renderer:
def simplified_text_chunks(
text: AnyText,
render_engine: TextRenderer,
*,
font_face: fonts.FontFace,
) -> Iterable[tuple[str, Matrix44, float]]:
"""Splits a complex text entity into simple chunks of text which can all be
rendered the same way:
render the string (which will not contain any newlines) with the given
cap_height with (left, baseline) at (0, 0) then transform it with the given
matrix to move it into place.
"""
alignment = _get_alignment(text)
box_width = _get_text_width(text)
cap_height = _get_cap_height(text)
lines = _split_into_lines(
text,
box_width,
lambda s: render_engine.get_text_line_width(s, font_face, cap_height),
)
line_spacing = _get_line_spacing(text, cap_height)
line_widths = [
render_engine.get_text_line_width(line, font_face, cap_height) for line in lines
]
font_measurements = render_engine.get_font_measurements(font_face, cap_height)
anchor, line_xs, line_ys = _apply_alignment(
alignment, line_widths, line_spacing, box_width, font_measurements
)
rotation = _get_rotation(text)
# first_line_width is used for TEXT, ATTRIB and ATTDEF stretching
if line_widths:
first_line_width = line_widths[0]
else: # no text lines -> no output, value is not important
first_line_width = 1.0
extra_transform = _get_extra_transform(text, first_line_width)
insert = _get_wcs_insert(text)
whole_text_transform = (
Matrix44.translate(-anchor[0], -anchor[1], 0)
@ extra_transform
@ rotation
@ Matrix44.translate(*insert.xyz)
)
for i, (line, line_x, line_y) in enumerate(zip(lines, line_xs, line_ys)):
transform = Matrix44.translate(line_x, line_y, 0) @ whole_text_transform
yield line, transform, cap_height
@@ -0,0 +1,46 @@
# Copyright (c) 2022-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TypeVar, TYPE_CHECKING
import abc
from ezdxf.fonts import fonts
if TYPE_CHECKING:
from ezdxf.npshapes import NumpyPath2d
T = TypeVar("T")
class TextRenderer(abc.ABC):
"""Minimal requirement to be usable as a universal text renderer"""
@abc.abstractmethod
def get_font_measurements(
self, font_face: fonts.FontFace, cap_height: float = 1.0
) -> fonts.FontMeasurements:
...
@abc.abstractmethod
def get_text_line_width(
self,
text: str,
font_face: fonts.FontFace,
cap_height: float = 1.0,
) -> float:
...
@abc.abstractmethod
def get_text_path(
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
) -> NumpyPath2d:
...
@abc.abstractmethod
def get_text_glyph_paths(
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
) -> list[NumpyPath2d]:
...
@abc.abstractmethod
def is_stroke_font(self, font_face: fonts.FontFace) -> bool:
...
@@ -0,0 +1,10 @@
# Copyright (c) 2020-2022, Matthew Broadway
# License: MIT License
from typing import Callable
from typing_extensions import TypeAlias
from ezdxf.entities import DXFGraphic
LayerName: TypeAlias = str
Color: TypeAlias = str
Radians: TypeAlias = float
FilterFunc: TypeAlias = Callable[[DXFGraphic], bool]
@@ -0,0 +1,72 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING
from ezdxf.fonts import fonts
from ezdxf.fonts.font_measurements import FontMeasurements
from .text_renderer import TextRenderer
if TYPE_CHECKING:
from ezdxf.npshapes import NumpyPath2d
class UnifiedTextRenderer(TextRenderer):
"""This text renderer supports .ttf, .ttc, .otf, .shx, .shp and .lff fonts.
The resolving order for .shx fonts is applied in the RenderContext.add_text_style()
method.
"""
def __init__(self) -> None:
self._font_cache: dict[str, fonts.AbstractFont] = dict()
def get_font(self, font_face: fonts.FontFace) -> fonts.AbstractFont:
if not font_face.filename and font_face.family:
found = fonts.find_best_match(
family=font_face.family,
weight=700 if font_face.is_bold else 400,
italic=font_face.is_italic,
)
if found is not None:
font_face = found
key = font_face.filename.lower()
try:
return self._font_cache[key]
except KeyError:
pass
abstract_font = fonts.make_font(font_face.filename, 1.0)
self._font_cache[key] = abstract_font
return abstract_font
def is_stroke_font(self, font_face: fonts.FontFace) -> bool:
abstract_font = self.get_font(font_face)
return abstract_font.font_render_type == fonts.FontRenderType.STROKE
def get_font_measurements(
self, font_face: fonts.FontFace, cap_height: float = 1.0
) -> FontMeasurements:
abstract_font = self.get_font(font_face)
return abstract_font.measurements.scale(cap_height)
def get_text_path(
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
) -> NumpyPath2d:
abstract_font = self.get_font(font_face)
return abstract_font.text_path_ex(text, cap_height)
def get_text_glyph_paths(
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
) -> list[NumpyPath2d]:
abstract_font = self.get_font(font_face)
return abstract_font.text_glyph_paths(text, cap_height)
def get_text_line_width(
self,
text: str,
font_face: fonts.FontFace,
cap_height: float = 1.0,
) -> float:
abstract_font = self.get_font(font_face)
return abstract_font.text_width_ex(text, cap_height)
@@ -0,0 +1,5 @@
# Copyright (c) 2020-2021, Manfred Moitzi
# License: MIT License
from .loader import load, readfile
from .fileheader import FileHeader
@@ -0,0 +1,82 @@
# Copyright (c) 2020-2021, Manfred Moitzi
# License: MIT License
from typing import Iterable, Tuple
import struct
from ezdxf.tools.binarydata import BitStream
from ezdxf.entities import DXFClass
from .const import *
from .crc import crc8
from .fileheader import FileHeader
from .header_section import DwgSectionLoader
def load_classes_section(specs: FileHeader, data: Bytes, crc_check=False):
if specs.version <= ACAD_2000:
return DwgClassesSectionR2000(specs, data, crc_check)
else:
return DwgClassesSectionR2004(specs, data, crc_check)
class DwgClassesSectionR2000(DwgSectionLoader):
def load_data_section(self, data: Bytes) -> Bytes:
if self.specs.version > ACAD_2000:
raise DwgVersionError(self.specs.version)
seeker, section_size = self.specs.sections[CLASSES_ID]
return data[seeker : seeker + section_size]
def load_classes(self) -> Iterable[Tuple[int, DXFClass]]:
sentinel = self.data[:SENTINEL_SIZE]
if (
sentinel
!= b"\x8D\xA1\xC4\xB8\xC4\xA9\xF8\xC5\xC0\xDC\xF4\x5F\xE7\xCF\xB6\x8A"
):
raise DwgCorruptedClassesSection(
"Sentinel for start of CLASSES section not found."
)
start_index = SENTINEL_SIZE
bs = BitStream(
self.data[start_index:],
dxfversion=self.specs.version,
encoding=self.specs.encoding,
)
class_data_size = bs.read_unsigned_long() # data size in bytes
end_sentinel_index = SENTINEL_SIZE + 6 + class_data_size
end_index = end_sentinel_index - 2
end_bit_index = (3 + class_data_size) << 3
while bs.bit_index < end_bit_index:
class_num = bs.read_bit_short()
dxfattribs = {
"flags": bs.read_bit_short(), # version?
"app_name": bs.read_text(),
"cpp_class_name": bs.read_text(),
"name": bs.read_text(),
"was_a_proxy": bs.read_bit(),
"is_an_entity": int(bs.read_bit_short() == 0x1F2),
}
yield class_num, DXFClass.new(dxfattribs=dxfattribs)
if self.crc_check and False:
check = struct.unpack_from("<H", self.data, end_index)[0]
# TODO: classes crc check
# Which data should be checked? This is not correct:
crc = crc8(self.data[start_index:end_index])
if check != crc:
raise CRCError("CRC error in classes section.")
sentinel = self.data[
end_sentinel_index : end_sentinel_index + SENTINEL_SIZE
]
if (
sentinel
!= b"\x72\x5E\x3B\x47\x3B\x56\x07\x3A\x3F\x23\x0B\xA0\x18\x30\x49\x75"
):
raise DwgCorruptedClassesSection(
"Sentinel for end of CLASSES section not found."
)
class DwgClassesSectionR2004(DwgClassesSectionR2000):
def load_data(self, data: Bytes) -> Bytes:
raise NotImplementedError()
@@ -0,0 +1,45 @@
# Copyright (c) 2020-2021, Manfred Moitzi
# License: MIT License
from typing import Union
ACAD_13 = "AC1012"
ACAD_14 = "AC1014"
ACAD_2000 = "AC1015"
ACAD_2004 = "AC1018"
ACAD_2007 = "AC1021"
ACAD_2010 = "AC1024"
ACAD_2013 = "AC1027"
ACAD_2018 = "AC1032"
ACAD_LATEST = ACAD_2018
SUPPORTED_VERSIONS = [ACAD_13, ACAD_14, ACAD_2000]
HEADER_ID = 0
CLASSES_ID = 1
OBJECTS_ID = 2
SENTINEL_SIZE = 16
Bytes = Union[bytes, bytearray, memoryview]
class DwgError(Exception):
pass
class DwgVersionError(DwgError):
pass
class DwgCorruptedFileHeader(DwgError):
pass
class DwgCorruptedClassesSection(DwgError):
pass
class DwgCorruptedHeaderSection(DwgError):
pass
class CRCError(DwgError):
pass
@@ -0,0 +1,83 @@
# Copyright (c) 2020-2021, Manfred Moitzi
# License: MIT License
__all__ = ["crc8", "crc32"]
from .const import Bytes
def crc8(data: Bytes, seed: int = 0) -> int:
for byte in data:
index = byte ^ (seed & 0xFF)
seed = (seed >> 8) & 0xFF
seed ^= CRC8_TABLE[index & 0xFF]
return seed
def crc32(data: Bytes, seed: int = 0) -> int:
inverted_crc = ~seed
for byte in data:
inverted_crc = (inverted_crc >> 8) ^ CRC32_TABLE[
(inverted_crc ^ byte) & 0xFF
]
return ~inverted_crc
# fmt: off
# Source: Open Design Specification for .dwg
CRC8_TABLE = [
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1,
0xC481, 0x0440, 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, 0x0A00, 0xCAC1, 0xCB81, 0x0B40,
0xC901, 0x09C0, 0x0880, 0xC841, 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, 0x1E00, 0xDEC1,
0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1,
0xF281, 0x3240, 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, 0x3C00, 0xFCC1, 0xFD81, 0x3D40,
0xFF01, 0x3FC0, 0x3E80, 0xFE41, 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, 0x2800, 0xE8C1,
0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0,
0x2080, 0xE041, 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, 0x6600, 0xA6C1, 0xA781, 0x6740,
0xA501, 0x65C0, 0x6480, 0xA441, 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, 0xAA01, 0x6AC0,
0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1,
0xB681, 0x7640, 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, 0x5000, 0x90C1, 0x9181, 0x5140,
0x9301, 0x53C0, 0x5280, 0x9241, 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, 0x9C01, 0x5CC0,
0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0,
0x4C80, 0x8C41, 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, 0x8201, 0x42C0, 0x4380, 0x8341,
0x4100, 0x81C1, 0x8081, 0x4040,
]
# Source: Open Design Specification for .dwg
CRC32_TABLE = [
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, 0x0edb8832,
0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2,
0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a,
0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3,
0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423,
0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab,
0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4,
0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950,
0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce, 0xa3bc0074,
0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525,
0x206f85b3, 0xb966d409, 0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81,
0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615,
0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76,
0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e,
0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, 0xd80d2bda, 0xaf0a1b4c, 0x36034af6,
0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7,
0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f,
0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7,
0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, 0xa00ae278,
0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc,
0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, 0xbdbdf21c, 0xcabac28a, 0x53b39330,
0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d,
]
# fmt: on
@@ -0,0 +1,92 @@
# Copyright (c) 2020-2021, Manfred Moitzi
# License: MIT License
from typing import Dict, Tuple
import struct
from .const import *
from .crc import crc8
codepage_to_encoding = {
37: "cp874", # Thai,
38: "cp932", # Japanese
39: "gbk", # UnifiedChinese
40: "cp949", # Korean
41: "cp950", # TradChinese
28: "cp1250", # CentralEurope
29: "cp1251", # Cyrillic
30: "cp1252", # WesternEurope
32: "cp1253", # Greek
33: "cp1254", # Turkish
34: "cp1255", # Hebrew
35: "cp1256", # Arabic
36: "cp1257", # Baltic
}
FILE_HEADER_MAGIC = {
3: 0xA598,
4: 0x8101,
5: 0x3CC4,
6: 0x8461,
}
class FileHeader:
def __init__(self, data: Bytes, crc_check=False):
self.crc_check = crc_check
if len(data) < 6:
raise DwgVersionError("Not a DWG file.")
ver = data[:6].decode(errors="ignore") # type: ignore
if ver not in SUPPORTED_VERSIONS:
raise DwgVersionError(
f"Not a DWG file or unsupported DWG version, signature: {ver}."
)
self.version: str = ver
codepage: int = struct.unpack_from("<h", data, 0x13)[0]
self.encoding = codepage_to_encoding.get(codepage, "cp1252")
self.maintenance_release_version = data[0xB]
self.sections: Dict[int, Tuple[int, int]] = dict()
if self.version <= ACAD_2000:
self.r2000_header(data)
else:
raise DwgVersionError(self.version)
def r2000_header(self, data: Bytes):
index = 0x15
section_count: int = struct.unpack_from("<L", data, index)[0]
index += 4
fmt = "<BLL"
record_size = struct.calcsize(fmt)
for record in range(section_count):
# 0: HEADER_ID
# 1: CLASSES_ID
# 2: OBJECTS_ID
num, seeker, size = struct.unpack_from(fmt, data, index)
index += record_size
self.sections[num] = (seeker, size)
if self.crc_check:
# CRC from first byte of file until start of crc value
check = (
crc8(data[:index], seed=0)
^ FILE_HEADER_MAGIC[len(self.sections)]
)
crc = struct.unpack_from("<H", data, index)[0]
if crc != check:
raise CRCError("CRC error in file header.")
index += 2
sentinel = data[index : index + SENTINEL_SIZE]
if (
sentinel
!= b"\x95\xA0\x4E\x28\x99\x82\x1A\xE5\x5E\x41\xE0\x5F\x9D\x3A\x4D\x00"
):
raise DwgCorruptedFileHeader(
"Corrupted DXF R13/14/2000 file header."
)
def print(self):
print(f"DWG version: {self.version}")
print(f"encoding: {self.encoding}")
print(f"Records: {len(self.sections)}")
print("Header: seeker {0[0]} size: {0[1]}".format(self.sections[0]))
print("Classes: seeker {0[0]} size: {0[1]}".format(self.sections[1]))
print("Objects: seeker {0[0]} size: {0[1]}".format(self.sections[2]))
@@ -0,0 +1,657 @@
# Copyright (c) 2020-2021, Manfred Moitzi
# License: MIT License
from typing import Dict, Any, List, Tuple
from abc import abstractmethod
import struct
from ezdxf.lldxf.const import acad_release_to_dxf_version
from ezdxf.tools.binarydata import BitStream
from .const import *
from .crc import crc8
from .fileheader import FileHeader
def load_header_section(specs: FileHeader, data: Bytes, crc_check=False):
if specs.version <= ACAD_2000:
return DwgHeaderSectionR2000(specs, data, crc_check)
else:
return DwgHeaderSectionR2004(specs, data, crc_check)
class DwgSectionLoader:
def __init__(self, specs: FileHeader, data: Bytes, crc_check=False):
self.specs = specs
self.crc_check = crc_check
self.data = self.load_data_section(data)
@abstractmethod
def load_data_section(self, data: Bytes) -> Bytes:
...
class DwgHeaderSectionR2000(DwgSectionLoader):
def load_data_section(self, data: Bytes) -> Bytes:
if self.specs.version > ACAD_2000:
raise DwgVersionError(self.specs.version)
seeker, section_size = self.specs.sections[HEADER_ID]
return data[seeker : seeker + section_size]
def load_header_vars(self) -> Dict:
data = self.data
sentinel = data[:16]
if (
sentinel
!= b"\xCF\x7B\x1F\x23\xFD\xDE\x38\xA9\x5F\x7C\x68\xB8\x4E\x6D\x33\x5F"
):
raise DwgCorruptedHeaderSection(
"Sentinel for start of HEADER section not found."
)
index = 16
size = struct.unpack_from("<L", data, index)[0]
index += 4
bs = BitStream(
data[index : index + size],
dxfversion=self.specs.version,
encoding=self.specs.encoding,
)
hdr_vars = parse_header(bs)
index += size
if self.crc_check:
check = struct.unpack_from("<H", data, index)[0]
# CRC of data from end of sentinel until start of crc value
crc = crc8(data[16:-18], seed=0xC0C1)
if check != crc:
raise CRCError("CRC error in header section.")
sentinel = data[-16:]
if (
sentinel
!= b"\x30\x84\xE0\xDC\x02\x21\xC7\x56\xA0\x83\x97\x47\xB1\x92\xCC\xA0"
):
raise DwgCorruptedHeaderSection(
"Sentinel for end of HEADER section not found."
)
return hdr_vars
class DwgHeaderSectionR2004(DwgHeaderSectionR2000):
def load_data(self, data: Bytes) -> Bytes:
raise NotImplementedError()
CMD_SET_VERSION = "ver"
CMD_SKIP_BITS = "skip_bits"
CMD_SKIP_NEXT_IF = "skip_next_if"
CMD_SET_VAR = "var"
def _min_max_versions(version: str) -> Tuple[str, str]:
min_ver = ACAD_13
max_ver = ACAD_LATEST
if version != "all":
v = version.split("-")
if len(v) > 1:
min_ver = acad_release_to_dxf_version[v[0].strip()]
max_ver = acad_release_to_dxf_version[v[1].strip()]
else:
v_str: str = v[0].strip()
if v_str[-1] == "+":
min_ver = acad_release_to_dxf_version[v_str[:-1]]
else:
min_ver = max_ver = acad_release_to_dxf_version[v_str]
return min_ver, max_ver
def load_commands(desc: str) -> List[Tuple[str, Any]]:
commands = []
lines = desc.split("\n")
for line in lines:
line = line.strip()
if not line or line[0] == "#":
continue
try:
command, param = line.split(":")
except ValueError:
raise ValueError(f"Unpack Error in line: {line}")
command = command.strip()
param = param.split("#")[0].strip()
if command == CMD_SET_VERSION:
commands.append((CMD_SET_VERSION, _min_max_versions(param)))
elif command in {CMD_SKIP_BITS, CMD_SKIP_NEXT_IF}:
commands.append((command, param)) # type: ignore
elif command[0] == "$":
commands.append((CMD_SET_VAR, (command, param)))
else:
raise ValueError(f"Unknown command: {command}")
return commands
def parse_bitstream(
bs: BitStream, commands: List[Tuple[str, Any]]
) -> Dict[str, Any]:
version = bs.dxfversion
min_ver = ACAD_13
max_ver = ACAD_LATEST
hdr_vars: Dict[str, Any] = dict()
skip_next_cmd = False
for cmd, params in commands:
if skip_next_cmd:
skip_next_cmd = False
continue
if cmd == CMD_SET_VERSION:
min_ver, max_ver = params
elif cmd == CMD_SKIP_BITS:
bs.skip(int(params))
elif cmd == CMD_SKIP_NEXT_IF:
skip_next_cmd = eval(params, None, {"header": hdr_vars})
elif cmd == CMD_SET_VAR:
if min_ver <= version <= max_ver:
name, code = params
hdr_vars[name] = bs.read_code(code)
else:
raise ValueError(f"Unknown command: {cmd}")
return hdr_vars
def parse_header(bs: BitStream) -> Dict[str, Any]:
commands = load_commands(HEADER_DESCRIPTION)
return parse_bitstream(bs, commands)
HEADER_DESCRIPTION = """
ver: R2007
$SIZE_IN_BITS: RL # Size in bits
ver: R2013+
$REQUIREDVERSIONS: BLL # default value 0, read only
ver: all
$UNKNOWN: BD # Unknown, default value 412148564080.0
$UNKNOWN: BD # Unknown, default value 1.0
$UNKNOWN: BD # Unknown, default value 1.0
$UNKNOWN: BD # Unknown, default value 1.0
$UNKNOWN: TV # Unknown text string, default ""
$UNKNOWN: TV # Unknown text string, default ""
$UNKNOWN: TV # Unknown text string, default ""
$UNKNOWN: TV # Unknown text string, default ""
$UNKNOWN: BL # Unknown long, default value 24L
$UNKNOWN: BL # Unknown long, default value 0L;
ver: R13-R14
$UNKNOWN: BS # Unknown short, default value 0
ver: R13-R2000
$CURRENT_VIEWPORT_ENTITY_HEADER: H # Handle of the current viewport entity header (hard pointer)
ver: all
$DIMASO: B
$DIMSHO: B
ver: R13-R14
$DIMSAV: B # Undocumented
ver: all
$PLINEGEN: B
$ORTHOMODE: B
$REGENMODE: B
$FILLMODE: B
$QTEXTMODE: B
$PSLTSCALE: B
$LIMCHECK: B
ver: R13-R14
$BLIPMODE: B
ver: R2004+
$UNKNOWN: B # Undocumented
ver: all
$USRTIMER: B # (User timer on/off)
$SKPOLY: B
$ANGDIR: B
$SPLFRAME: B
ver: R13-R14
$ATTREQ: B
$ATTDIA: B
ver: all
$MIRRTEXT: B
$WORLDVIEW: B
ver: R13-R14
$WIREFRAME: B # Undocumented.
ver: all
$TILEMODE: B
$PLIMCHECK: B
$VISRETAIN: B
ver: R13-R14
$DELOBJ: B
ver: all
$DISPSILH: B
$PELLIPSE: B # (not present in DXF)
$PROXYGRAPHICS: BS
ver: R13-R14
$DRAGMODE: BS
ver: all
$TREEDEPTH: BS
$LUNITS: BS
$LUPREC: BS
$AUNITS: BS
$AUPREC: BS
ver: R13-R14
$OSMODE: BS
ver: all
$ATTMODE: BS
ver: R13-R14
$COORDS: BS
ver: all
$PDMODE: BS
ver: R13-R14
$PICKSTYLE: BS
ver: R2004+
$UNKNOWN: BL
$UNKNOWN: BL
$UNKNOWN: BL
ver: all
$USERI1: BS
$USERI2: BS
$USERI3: BS
$USERI4: BS
$USERI5: BS
$SPLINESEGS: BS
$SURFU: BS
$SURFV: BS
$SURFTYPE: BS
$SURFTAB1: BS
$SURFTAB2: BS
$SPLINETYPE: BS
$SHADEDGE: BS
$SHADEDIF: BS
$UNITMODE: BS
$MAXACTVP: BS
$ISOLINES: BS
$CMLJUST: BS
$TEXTQLTY: BS
$LTSCALE: BD
$TEXTSIZE: BD
$TRACEWID: BD
$SKETCHINC: BD
$FILLETRAD: BD
$THICKNESS: BD
$ANGBASE: BD
$PDSIZE: BD
$PLINEWID: BD
$USERR1: BD
$USERR2: BD
$USERR3: BD
$USERR4: BD
$USERR5: BD
$CHAMFERA: BD
$CHAMFERB: BD
$CHAMFERC: BD
$CHAMFERD: BD
$FACETRES: BD
$CMLSCALE: BD
$CELTSCALE: BD
ver: R13-R2004
$MENUNAME: TV
ver: all
$TDCREATE: BL # (Julian day)
$TDCREATE: BL # (Milliseconds into the day)
$TDUPDATE: BL # (Julian day)
$TDUPDATE: BL # (Milliseconds into the day)
ver: R2004+
$UNKNOWN: BL
$UNKNOWN: BL
$UNKNOWN: BL
ver: all
$TDINDWG: BL # (Days)
$TDINDWG: BL # (Milliseconds into the day)
$TDUSRTIMER: BL # (Days)
$TDUSRTIMER: BL # (Milliseconds into the day)
$CECOLOR: CMC
# with an 8-bit length specifier preceding the handle bytes (standard hex handle form) (code 0).
# The HANDSEED is not part of the handle stream, but of the normal data stream (relevant for R21 and later).
$HANDSEED: H # The next handle
$CLAYER: H # (hard pointer)
$TEXTSTYLE: H # (hard pointer)
$CELTYPE: H # (hard pointer)
ver: R2007+
$CMATERIAL: H # (hard pointer)
ver: all
$DIMSTYLE: H # (hard pointer)
$CMLSTYLE: H # (hard pointer)
ver: R2000+
$PSVPSCALE: BD
ver: all
$PINSBASE: 3BD # (PSPACE)
$PEXTMIN: 3BD # (PSPACE)
$PEXTMAX: 3BD # (PSPACE)
$PLIMMIN: 2RD # (PSPACE)
$PLIMMAX: 2RD # (PSPACE)
$PELEVATION: BD # (PSPACE)
$PUCSORG: 3BD # (PSPACE)
$PUCSXDIR: 3BD # (PSPACE)
$PUCSYDIR: 3BD # (PSPACE)
$PUCSNAME: H # (PSPACE) (hard pointer)
ver: R2000+
$PUCSORTHOREF: H # (hard pointer)
$PUCSORTHOVIEW: BS
$PUCSBASE: H # (hard pointer)
$PUCSORGTOP: 3BD
$PUCSORGBOTTOM: 3BD
$PUCSORGLEFT: 3BD
$PUCSORGRIGHT: 3BD
$PUCSORGFRONT: 3BD
$PUCSORGBACK: 3BD
ver: all
$INSBASE: 3BD # (MSPACE)
$EXTMIN: 3BD # (MSPACE)
$EXTMAX: 3BD # (MSPACE)
$LIMMIN: 2RD # (MSPACE)
$LIMMAX: 2RD # (MSPACE)
$ELEVATION: BD # (MSPACE)
$UCSORG: 3BD # (MSPACE)
$UCSXDIR: 3BD # (MSPACE)
$UCSYDIR: 3BD # (MSPACE)
$UCSNAME: H # (MSPACE) (hard pointer)
ver: R2000+
$UCSORTHOREF: H # (hard pointer)
$UCSORTHOVIEW: BS
$UCSBASE: H # (hard pointer)
$UCSORGTOP: 3BD
$UCSORGBOTTOM: 3BD
$UCSORGLEFT: 3BD
$UCSORGRIGHT: 3BD
$UCSORGFRONT: 3BD
$UCSORGBACK: 3BD
$DIMPOST: TV
$DIMAPOST: TV
ver: R13-R14
$DIMTOL: B
$DIMLIM: B
$DIMTIH: B
$DIMTOH: B
$DIMSE1: B
$DIMSE2: B
$DIMALT: B
$DIMTOFL: B
$DIMSAH: B
$DIMTIX: B
$DIMSOXD: B
$DIMALTD: RC
$DIMZIN: RC
$DIMSD1: B
$DIMSD2: B
$DIMTOLJ: RC
$DIMJUST: RC
$DIMFIT: RC
$DIMUPT: B
$DIMTZIN: RC
$DIMALTZ: RC
$DIMALTTZ: RC
$DIMTAD: RC
$DIMUNIT: BS
$DIMAUNIT: BS
$DIMDEC: BS
$DIMTDEC: BS
$DIMALTU: BS
$DIMALTTD: BS
$DIMTXSTY: H # (hard pointer)
ver: all
$DIMSCALE: BD
$DIMASZ: BD
$DIMEXO: BD
$DIMDLI: BD
$DIMEXE: BD
$DIMRND: BD
$DIMDLE: BD
$DIMTP: BD
$DIMTM: BD
ver: R2007+
$DIMFXL: BD
$DIMJOGANG: BD
$DIMTFILL: BS
$DIMTFILLCLR: CMC
ver: R2000+
$DIMTOL: B
$DIMLIM: B
$DIMTIH: B
$DIMTOH: B
$DIMSE1: B
$DIMSE2: B
$DIMTAD: BS
$DIMZIN: BS
$DIMAZIN: BS
ver: R2007+
$DIMARCSYM: BS
ver: all
$DIMTXT: BD
$DIMCEN: BD
$DIMTSZ: BD
$DIMALTF: BD
$DIMLFAC: BD
$DIMTVP: BD
$DIMTFAC: BD
$DIMGAP: BD
ver: R13-R14
$DIMPOST: T
$DIMAPOST: T
$DIMBLK: T
$DIMBLK1: T
$DIMBLK2: T
ver: R2000+
$DIMALTRND: BD
$DIMALT: B
$DIMALTD: BS
$DIMTOFL: B
$DIMSAH: B
$DIMTIX: B
$DIMSOXD: B
ver: all
$DIMCLRD: CMC
$DIMCLRE: CMC
$DIMCLRT: CMC
ver: R2000+
$DIMADEC: BS
$DIMDEC: BS
$DIMTDEC: BS
$DIMALTU: BS
$DIMALTTD: BS
$DIMAUNIT: BS
$DIMFRAC: BS
$DIMLUNIT: BS
$DIMDSEP: BS
$DIMTMOVE: BS
$DIMJUST: BS
$DIMSD1: B
$DIMSD2: B
$DIMTOLJ: BS
$DIMTZIN: BS
$DIMALTZ: BS
$DIMALTTZ: BS
$DIMUPT: B
$DIMATFIT: BS
ver: R2007+
$DIMFXLON: B
ver: R2010+
$DIMTXTDIRECTION: B
$DIMALTMZF: BD
$DIMALTMZS: T
$DIMMZF: BD
$DIMMZS: T
ver: R2000+
$DIMTXSTY: H # (hard pointer)
$DIMLDRBLK: H # (hard pointer)
$DIMBLK: H # (hard pointer)
$DIMBLK1: H # (hard pointer)
$DIMBLK2: H # (hard pointer)
ver: R2007+
$DIMLTYPE: H # (hard pointer)
$DIMLTEX1: H # (hard pointer)
$DIMLTEX2: H # (hard pointer)
ver: R2000+
$DIMLWD: BS
$DIMLWE: BS
ver: all
$BLOCK_CONTROL_OBJECT: H # (hard owner) Block Record Table
$LAYER_CONTROL_OBJECT: H # (hard owner) Layer Table
$STYLE_CONTROL_OBJECT: H # (hard owner) Style Table
$LINETYPE_CONTROL_OBJECT: H # (hard owner) Linetype Table
$VIEW_CONTROL_OBJECT: H # (hard owner) View table
$UCS_CONTROL_OBJECT: H # (hard owner) UCS Table
$VPORT_CONTROL_OBJECT: H # (hard owner) Viewport table
$APPID_CONTROL_OBJECT: H # (hard owner) AppID Table
$DIMSTYLE_CONTROL_OBJECT: H # (hard owner) Dimstyle Table
ver: R13-R2000
$VIEWPORT_ENTITY_HEADER_CONTROL_OBJECT: H # (hard owner)
ver: all
$ACAD_GROUP_DICTIONARY: H # (hard pointer)
$ACAD_MLINESTYLE_DICTIONARY: H # (hard pointer)
$ROOT_DICTIONARY: H # (NAMED OBJECTS) (hard owner)
ver: R2000+
$TSTACKALIGN: BS # default = 1 (not present in DXF)
$TSTACKSIZE: BS # default = 70 (not present in DXF)
$HYPERLINKBASE: TV
$STYLESHEET: TV
$LAYOUTS_DICTIONARY: H # (hard pointer)
$PLOTSETTINGS_DICTIONARY: H # (hard pointer)
$PLOTSTYLES_DICTIONARY: H # (hard pointer)
ver: R2004+
$MATERIALS_DICTIONARY: H # (hard pointer)
$COLORS_DICTIONARY: H # (hard pointer)
ver: R2007+
$VISUALSTYLE_DICTIONARY: H # (hard pointer)
ver: R2013+
$UNKNOWN: H # (hard pointer)
ver: R2000+
$R2000_PLUS_FLAGS: BL
# CELWEIGHT Flags & 0x001F
# ENDCAPS Flags & 0x0060
# JOINSTYLE Flags & 0x0180
# LWDISPLAY !(Flags & 0x0200)
# XEDIT !(Flags & 0x0400)
# EXTNAMES Flags & 0x0800
# PSTYLEMODE Flags & 0x2000
# OLESTARTUP Flags & 0x4000
$INSUNITS: BS
$CEPSNTYPE: BS
skip_next_if: header['$CEPSNTYPE'] != 3
$CPSNID: H # (present only if CEPSNTYPE == 3) (hard pointer)
$FINGERPRINTGUID: TV
$VERSIONGUID: TV
ver: R2004+
$SORTENTS: RC
$INDEXCTL: RC
$HIDETEXT: RC
$XCLIPFRAME: RC # before R2010 the value can be 0 or 1 only.
$DIMASSOC: RC
$HALOGAP: RC
$OBSCUREDCOLOR: BS
$INTERSECTIONCOLOR: BS
$OBSCUREDLTYPE: RC
$INTERSECTIONDISPLAY: RC
$PROJECTNAME: TV
ver: all
$PAPER_SPACE_BLOCK_RECORD: H # (hard pointer)
$MODEL_SPACE_BLOCK_RECORD: H # (hard pointer)
$BYLAYER_LTYPE: H # (hard pointer)
$BYBLOCK_LTYPE: H # (hard pointer)
$CONTINUOUS_LTYPE: H # (hard pointer)
ver: R2007+
$CAMERADISPLAY: B
$UNKNOWN: BL
$UNKNOWN: BL
$UNKNOWN: BD
$STEPSPERSEC: BD
$STEPSIZE: BD
$3DDWFPREC: BD
$LENSLENGTH: BD
$CAMERAHEIGHT: BD
$SOLIDHIST: RC
$SHOWHIST: RC
$PSOLWIDTH: BD
$PSOLHEIGHT: BD
$LOFTANG1: BD
$LOFTANG2: BD
$LOFTMAG1: BD
$LOFTMAG2: BD
$LOFTPARAM: BS
$LOFTNORMALS: RC
$LATITUDE: BD
$LONGITUDE: BD
$NORTHDIRECTION: BD
$TIMEZONE: BL
$LIGHTGLYPHDISPLAY: RC
$TILEMODELIGHTSYNCH: RC
$DWFFRAME: RC
$DGNFRAME: RC
$UNKNOWN: B
$INTERFERECOLOR: CMC
$INTERFEREOBJVS: H # (hard pointer)
$INTERFEREVPVS: H # (hard pointer)
$CSHADOW: RC
$UNKNOWN: BD
ver: R14+
$UNKNOWN: BS # short (type 5/6 only) these do not seem to be required,
$UNKNOWN: BS # short (type 5/6 only) even for type 5.
$UNKNOWN: BS # short (type 5/6 only)
$UNKNOWN: BS # short (type 5/6 only)
"""
@@ -0,0 +1,88 @@
# Copyright (c) 2020-2021, Manfred Moitzi
# License: MIT License
from typing import Dict
from ezdxf.document import Drawing
from ezdxf.tools import codepage
from ezdxf.sections.header import HeaderSection
from ezdxf.sections.classes import ClassesSection
from ezdxf.sections.tables import TablesSection
from ezdxf.sections.blocks import BlocksSection
from ezdxf.sections.entities import EntitySection
from ezdxf.sections.objects import ObjectsSection
from ezdxf.sections.acdsdata import AcDsDataSection
from .const import *
from .fileheader import FileHeader
from .header_section import load_header_section
from .classes_section import load_classes_section
__all__ = ["readfile", "load"]
def readfile(filename: str, crc_check=False) -> "Drawing":
data = open(filename, "rb").read()
return load(data, crc_check)
def load(data: bytes, crc_check=False) -> Drawing:
doc = DwgDocument(data, crc_check=crc_check)
doc.load()
return doc.doc
class DwgDocument:
def __init__(self, data: Bytes, crc_check=False):
self.data = memoryview(data)
self.crc_check = crc_check
self.specs = FileHeader(data, crc_check=crc_check)
self.doc: Drawing = self._setup_doc()
# Store DXF object types by class number:
self.dxf_object_types: Dict[int, str] = dict()
def _setup_doc(self) -> Drawing:
doc = Drawing(dxfversion=self.specs.version)
doc.encoding = self.specs.encoding
doc.header = HeaderSection.new()
# Setup basic header variables not stored in the header section of the DWG file.
doc.header["$ACADVER"] = self.specs.version
doc.header["$ACADMAINTVER"] = self.specs.maintenance_release_version
doc.header["$DWGCODEPAGE"] = codepage.tocodepage(self.specs.encoding)
doc.classes = ClassesSection(doc)
# doc.tables = TablesSection(doc)
# doc.blocks = BlocksSection(doc)
# doc.entities = EntitySection(doc)
# doc.objects = ObjectsSection(doc)
# doc.acdsdata = AcDsDataSection(doc)
return doc
def load(self):
self.load_header()
self.load_classes()
self.load_objects()
self.store_objects()
def load_header(self) -> None:
hdr_section = load_header_section(self.specs, self.data, self.crc_check)
hdr_vars = hdr_section.load_header_vars()
self.set_header_vars(hdr_vars)
def set_header_vars(self, hdr_vars: Dict):
pass
def load_classes(self) -> None:
cls_section = load_classes_section(
self.specs, self.data, self.crc_check
)
for class_num, dxfclass in cls_section.load_classes():
self.doc.classes.register(dxfclass)
self.dxf_object_types[class_num] = dxfclass.dxf.name
def load_objects(self) -> None:
pass
def store_objects(self) -> None:
pass
@@ -0,0 +1,904 @@
# Copyright (c) 2019-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Mapping, Optional
import json
from ezdxf.sections.tables import TABLENAMES
from ezdxf.lldxf.tags import Tags
from ezdxf.entities import BoundaryPathType, EdgeType
import numpy as np
if TYPE_CHECKING:
from ezdxf.lldxf.types import DXFTag
from ezdxf.entities import (
Insert,
MText,
LWPolyline,
Polyline,
Spline,
Leader,
Dimension,
Image,
Mesh,
Hatch,
MPolygon,
Wipeout,
)
from ezdxf.entities import DXFEntity, Linetype
from ezdxf.entities.polygon import DXFPolygon
from ezdxf.layouts import BlockLayout
__all__ = [
"entities_to_code",
"block_to_code",
"table_entries_to_code",
"black",
]
def black(code: str, line_length=88, fast: bool = True) -> str:
"""Returns the source `code` as a single string formatted by `Black`_
Requires the installed `Black`_ formatter::
pip3 install black
Args:
code: source code
line_length: max. source code line length
fast: ``True`` for fast mode, ``False`` to check that the reformatted
code is valid
Raises:
ImportError: Black is not available
.. _black: https://pypi.org/project/black/
"""
import black
mode = black.FileMode()
mode.line_length = line_length
return black.format_file_contents(code, fast=fast, mode=mode)
def entities_to_code(
entities: Iterable[DXFEntity],
layout: str = "layout",
ignore: Optional[Iterable[str]] = None,
) -> Code:
"""
Translates DXF entities into Python source code to recreate this entities
by ezdxf.
Args:
entities: iterable of DXFEntity
layout: variable name of the layout (model space or block) as string
ignore: iterable of entities types to ignore as strings
like ``['IMAGE', 'DIMENSION']``
Returns:
:class:`Code`
"""
code = _SourceCodeGenerator(layout=layout)
code.translate_entities(entities, ignore=ignore)
return code.code
def block_to_code(
block: BlockLayout,
drawing: str = "doc",
ignore: Optional[Iterable[str]] = None,
) -> Code:
"""
Translates a BLOCK into Python source code to recreate the BLOCK by ezdxf.
Args:
block: block definition layout
drawing: variable name of the drawing as string
ignore: iterable of entities types to ignore as strings
like ['IMAGE', 'DIMENSION']
Returns:
:class:`Code`
"""
assert block.block is not None
dxfattribs = _purge_handles(block.block.dxfattribs())
block_name = dxfattribs.pop("name")
base_point = dxfattribs.pop("base_point")
code = _SourceCodeGenerator(layout="b")
prolog = f'b = {drawing}.blocks.new("{block_name}", base_point={base_point}, dxfattribs={{'
code.add_source_code_line(prolog)
code.add_source_code_lines(_fmt_mapping(dxfattribs, indent=4))
code.add_source_code_line(" }")
code.add_source_code_line(")")
code.translate_entities(block, ignore=ignore)
return code.code
def table_entries_to_code(
entities: Iterable[DXFEntity], drawing="doc"
) -> Code:
code = _SourceCodeGenerator(doc=drawing)
code.translate_entities(entities)
return code.code
class Code:
"""Source code container."""
def __init__(self) -> None:
self.code: list[str] = []
# global imports -> indentation level 0:
self.imports: set[str] = set()
# layer names as string:
self.layers: set[str] = set()
# text style name as string, requires a TABLE entry:
self.styles: set[str] = set()
# line type names as string, requires a TABLE entry:
self.linetypes: set[str] = set()
# dimension style names as string, requires a TABLE entry:
self.dimstyles: set[str] = set()
# block names as string, requires a BLOCK definition:
self.blocks: set[str] = set()
def code_str(self, indent: int = 0) -> str:
"""Returns the source code as a single string.
Args:
indent: source code indentation count by spaces
"""
lead_str = " " * indent
return "\n".join(lead_str + line for line in self.code)
def black_code_str(self, line_length=88) -> str:
"""Returns the source code as a single string formatted by `Black`_
Args:
line_length: max. source code line length
Raises:
ImportError: Black is not available
"""
return black(self.code_str(), line_length)
def __str__(self) -> str:
"""Returns the source code as a single string."""
return self.code_str()
def import_str(self, indent: int = 0) -> str:
"""Returns required imports as a single string.
Args:
indent: source code indentation count by spaces
"""
lead_str = " " * indent
return "\n".join(lead_str + line for line in self.imports)
def add_import(self, statement: str) -> None:
"""Add import statement, identical import statements are merged
together.
"""
self.imports.add(statement)
def add_line(self, code: str, indent: int = 0) -> None:
"""Add a single source code line without line ending ``\\n``."""
self.code.append(" " * indent + code)
def add_lines(self, code: Iterable[str], indent: int = 0) -> None:
"""Add multiple source code lines without line ending ``\\n``."""
for line in code:
self.add_line(line, indent=indent)
def merge(self, code: Code, indent: int = 0) -> None:
"""Add another :class:`Code` object."""
# merge used resources
self.imports.update(code.imports)
self.layers.update(code.layers)
self.linetypes.update(code.linetypes)
self.styles.update(code.styles)
self.dimstyles.update(code.dimstyles)
self.blocks.update(code.blocks)
# append source code lines
self.add_lines(code.code, indent=indent)
_PURGE_DXF_ATTRIBUTES = {
"handle",
"owner",
"paperspace",
"material_handle",
"visualstyle_handle",
"plotstyle_handle",
}
def _purge_handles(attribs: dict) -> dict:
"""Purge handles from DXF attributes which will be invalid in a new
document, or which will be set automatically by adding an entity to a
layout (paperspace).
Args:
attribs: entity DXF attributes dictionary
"""
return {k: v for k, v in attribs.items() if k not in _PURGE_DXF_ATTRIBUTES}
def _fmt_mapping(mapping: Mapping, indent: int = 0) -> Iterable[str]:
# key is always a string
fmt = " " * indent + "'{}': {},"
for k, v in mapping.items():
assert isinstance(k, str)
if isinstance(v, str):
v = json.dumps(v) # for correct escaping of quotes
else:
v = str(v) # format uses repr() for Vec3s
yield fmt.format(k, v)
def _fmt_list(l: Iterable, indent: int = 0) -> Iterable[str]:
def cleanup(values: Iterable) -> Iterable:
for value in values:
if isinstance(value, np.float64):
yield float(value)
else:
yield value
fmt = " " * indent + "{},"
for v in l:
if not isinstance(v, (float, int, str)):
v = tuple(cleanup(v))
yield fmt.format(str(v))
def _fmt_api_call(
func_call: str, args: Iterable[str], dxfattribs: dict
) -> list[str]:
attributes = dict(dxfattribs)
args = list(args) if args else []
def fmt_keywords() -> Iterable[str]:
for arg in args:
if arg not in attributes:
continue
value = attributes.pop(arg)
if isinstance(value, str):
valuestr = json.dumps(value) # quoted string!
else:
valuestr = str(value)
yield " {}={},".format(arg, valuestr)
s = [func_call]
s.extend(fmt_keywords())
s.append(" dxfattribs={")
s.extend(_fmt_mapping(attributes, indent=8))
s.extend(
[
" },",
")",
]
)
return s
def _fmt_dxf_tags(tags: Iterable[DXFTag], indent: int = 0):
fmt = " " * indent + "dxftag({}, {}),"
for code, value in tags:
assert isinstance(code, int)
if isinstance(value, str):
value = json.dumps(value) # for correct escaping of quotes
else:
value = str(value) # format uses repr() for Vec3s
yield fmt.format(code, value)
class _SourceCodeGenerator:
"""
The :class:`_SourceCodeGenerator` translates DXF entities into Python source
code for creating the same DXF entity in another model space or block
definition.
:ivar code: list of source code lines without line endings
:ivar required_imports: list of import source code lines, which are required
to create executable Python code.
"""
def __init__(self, layout: str = "layout", doc: str = "doc"):
self.doc = doc
self.layout = layout
self.code = Code()
def translate_entity(self, entity: DXFEntity) -> None:
"""Translates one DXF entity into Python source code. The generated
source code is appended to the attribute `source_code`.
Args:
entity: DXFEntity object
"""
dxftype = entity.dxftype()
try:
entity_translator = getattr(self, "_" + dxftype.lower())
except AttributeError:
self.add_source_code_line(f'# unsupported DXF entity "{dxftype}"')
else:
entity_translator(entity)
def translate_entities(
self,
entities: Iterable[DXFEntity],
ignore: Optional[Iterable[str]] = None,
) -> None:
"""Translates multiple DXF entities into Python source code. The
generated source code is appended to the attribute `source_code`.
Args:
entities: iterable of DXFEntity
ignore: iterable of entities types to ignore as strings
like ['IMAGE', 'DIMENSION']
"""
ignore = set(ignore) if ignore else set()
for entity in entities:
if entity.dxftype() not in ignore:
self.translate_entity(entity)
def add_used_resources(self, dxfattribs: Mapping) -> None:
"""Register used resources like layers, line types, text styles and
dimension styles.
Args:
dxfattribs: DXF attributes dictionary
"""
if "layer" in dxfattribs:
self.code.layers.add(dxfattribs["layer"])
if "linetype" in dxfattribs:
self.code.linetypes.add(dxfattribs["linetype"])
if "style" in dxfattribs:
self.code.styles.add(dxfattribs["style"])
if "dimstyle" in dxfattribs:
self.code.dimstyles.add(dxfattribs["dimstyle"])
def add_import_statement(self, statement: str) -> None:
self.code.add_import(statement)
def add_source_code_line(self, code: str) -> None:
self.code.add_line(code)
def add_source_code_lines(self, code: Iterable[str]) -> None:
assert not isinstance(code, str)
self.code.add_lines(code)
def add_list_source_code(
self,
values: Iterable,
prolog: str = "[",
epilog: str = "]",
indent: int = 0,
) -> None:
fmt_str = " " * indent + "{}"
self.add_source_code_line(fmt_str.format(prolog))
self.add_source_code_lines(_fmt_list(values, indent=4 + indent))
self.add_source_code_line(fmt_str.format(epilog))
def add_dict_source_code(
self,
mapping: Mapping,
prolog: str = "{",
epilog: str = "}",
indent: int = 0,
) -> None:
fmt_str = " " * indent + "{}"
self.add_source_code_line(fmt_str.format(prolog))
self.add_source_code_lines(_fmt_mapping(mapping, indent=4 + indent))
self.add_source_code_line(fmt_str.format(epilog))
def add_tags_source_code(
self, tags: Tags, prolog="tags = Tags(", epilog=")", indent=4
):
fmt_str = " " * indent + "{}"
self.add_source_code_line(fmt_str.format(prolog))
self.add_source_code_lines(_fmt_dxf_tags(tags, indent=4 + indent))
self.add_source_code_line(fmt_str.format(epilog))
def generic_api_call(
self, dxftype: str, dxfattribs: dict, prefix: str = "e = "
) -> Iterable[str]:
"""Returns the source code strings to create a DXF entity by a generic
`new_entity()` call.
Args:
dxftype: DXF entity type as string, like 'LINE'
dxfattribs: DXF attributes dictionary
prefix: prefix string like variable assignment 'e = '
"""
dxfattribs = _purge_handles(dxfattribs)
self.add_used_resources(dxfattribs)
s = [
f"{prefix}{self.layout}.new_entity(",
f" '{dxftype}',",
" dxfattribs={",
]
s.extend(_fmt_mapping(dxfattribs, indent=8))
s.extend(
[
" },",
")",
]
)
return s
def api_call(
self,
api_call: str,
args: Iterable[str],
dxfattribs: dict,
prefix: str = "e = ",
) -> Iterable[str]:
"""Returns the source code strings to create a DXF entity by the
specialised API call.
Args:
api_call: API function call like 'add_line('
args: DXF attributes to pass as arguments
dxfattribs: DXF attributes dictionary
prefix: prefix string like variable assignment 'e = '
"""
dxfattribs = _purge_handles(dxfattribs)
func_call = f"{prefix}{self.layout}.{api_call}"
return _fmt_api_call(func_call, args, dxfattribs)
def new_table_entry(self, dxftype: str, dxfattribs: dict) -> Iterable[str]:
"""Returns the source code strings to create a new table entity by
ezdxf.
Args:
dxftype: table entry type as string, like 'LAYER'
dxfattribs: DXF attributes dictionary
"""
table = f"{self.doc}.{TABLENAMES[dxftype]}"
dxfattribs = _purge_handles(dxfattribs)
name = dxfattribs.pop("name")
s = [
f"if '{name}' not in {table}:",
f" t = {table}.new(",
f" '{name}',",
" dxfattribs={",
]
s.extend(_fmt_mapping(dxfattribs, indent=12))
s.extend(
[
" },",
" )",
]
)
return s
# simple graphical types
def _line(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.api_call("add_line(", ["start", "end"], entity.dxfattribs())
)
def _point(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.api_call("add_point(", ["location"], entity.dxfattribs())
)
def _circle(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.api_call(
"add_circle(", ["center", "radius"], entity.dxfattribs()
)
)
def _arc(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.api_call(
"add_arc(",
["center", "radius", "start_angle", "end_angle"],
entity.dxfattribs(),
)
)
def _text(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.api_call("add_text(", ["text"], entity.dxfattribs())
)
def _solid(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.generic_api_call("SOLID", entity.dxfattribs())
)
def _trace(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.generic_api_call("TRACE", entity.dxfattribs())
)
def _3dface(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.generic_api_call("3DFACE", entity.dxfattribs())
)
def _shape(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.api_call(
"add_shape(", ["name", "insert", "size"], entity.dxfattribs()
)
)
def _attrib(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.api_call(
"add_attrib(", ["tag", "text", "insert"], entity.dxfattribs()
)
)
def _attdef(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.generic_api_call("ATTDEF", entity.dxfattribs())
)
def _ellipse(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.api_call(
"add_ellipse(",
["center", "major_axis", "ratio", "start_param", "end_param"],
entity.dxfattribs(),
)
)
def _viewport(self, entity: DXFEntity) -> None:
self.add_source_code_lines(
self.generic_api_call("VIEWPORT", entity.dxfattribs())
)
self.add_source_code_line(
'# Set valid handles or remove attributes ending with "_handle", '
"otherwise the DXF file is invalid for AutoCAD"
)
# complex graphical types
def _insert(self, entity: Insert) -> None:
self.code.blocks.add(entity.dxf.name)
self.add_source_code_lines(
self.api_call(
"add_blockref(", ["name", "insert"], entity.dxfattribs()
)
)
if len(entity.attribs):
for attrib in entity.attribs:
dxfattribs = attrib.dxfattribs()
dxfattribs[
"layer"
] = entity.dxf.layer # set ATTRIB layer to same as INSERT
self.add_source_code_lines(
self.generic_api_call(
"ATTRIB", attrib.dxfattribs(), prefix="a = "
)
)
self.add_source_code_line("e.attribs.append(a)")
def _mtext(self, entity: MText) -> None:
self.add_source_code_lines(
self.generic_api_call("MTEXT", entity.dxfattribs())
)
# MTEXT content 'text' is not a single DXF tag and therefore not a DXF
# attribute
self.add_source_code_line("e.text = {}".format(json.dumps(entity.text)))
def _lwpolyline(self, entity: LWPolyline) -> None:
self.add_source_code_lines(
self.generic_api_call("LWPOLYLINE", entity.dxfattribs())
)
# lwpolyline points are not DXF attributes
self.add_list_source_code(
entity.get_points(), prolog="e.set_points([", epilog="])"
)
def _spline(self, entity: Spline) -> None:
self.add_source_code_lines(
self.api_call("add_spline(", ["degree"], entity.dxfattribs())
)
# spline points, knots and weights are not DXF attributes
if len(entity.fit_points):
self.add_list_source_code(
entity.fit_points, prolog="e.fit_points = [", epilog="]"
)
if len(entity.control_points):
self.add_list_source_code(
entity.control_points, prolog="e.control_points = [", epilog="]"
)
if len(entity.knots):
self.add_list_source_code(
entity.knots, prolog="e.knots = [", epilog="]"
)
if len(entity.weights):
self.add_list_source_code(
entity.weights, prolog="e.weights = [", epilog="]"
)
def _polyline(self, entity: Polyline) -> None:
self.add_source_code_lines(
self.generic_api_call("POLYLINE", entity.dxfattribs())
)
# polyline vertices are separate DXF entities and therefore not DXF attributes
for v in entity.vertices:
attribs = _purge_handles(v.dxfattribs())
location = attribs.pop("location")
if "layer" in attribs:
del attribs[
"layer"
] # layer is automatically set to the POLYLINE layer
# each VERTEX can have different DXF attributes: bulge, start_width, end_width ...
self.add_source_code_line(
f"e.append_vertex({str(location)}, dxfattribs={attribs})"
)
def _leader(self, entity: Leader):
self.add_source_code_line(
"# Dimension style attribute overriding is not supported!"
)
self.add_source_code_lines(
self.generic_api_call("LEADER", entity.dxfattribs())
)
self.add_list_source_code(
entity.vertices, prolog="e.set_vertices([", epilog="])"
)
def _dimension(self, entity: Dimension):
self.add_import_statement(
"from ezdxf.dimstyleoverride import DimStyleOverride"
)
self.add_source_code_line(
"# Dimension style attribute overriding is not supported!"
)
self.add_source_code_lines(
self.generic_api_call("DIMENSION", entity.dxfattribs())
)
self.add_source_code_lines(
[
"# You have to create the required graphical representation for ",
"# the DIMENSION entity as anonymous block, otherwise the DXF file",
"# is invalid for AutoCAD (but not for BricsCAD):",
"# DimStyleOverride(e).render()",
"",
]
)
def _image(self, entity: Image):
self.add_source_code_line(
"# Image requires IMAGEDEF and IMAGEDEFREACTOR objects in the "
"OBJECTS section!"
)
self.add_source_code_lines(
self.generic_api_call("IMAGE", entity.dxfattribs())
)
if len(entity.boundary_path):
self.add_list_source_code(
(v for v in entity.boundary_path), # just x, y axis
prolog="e.set_boundary_path([",
epilog="])",
)
self.add_source_code_line(
"# Set valid image_def_handle and image_def_reactor_handle, "
"otherwise the DXF file is invalid for AutoCAD"
)
def _wipeout(self, entity: Wipeout):
self.add_source_code_lines(
self.generic_api_call("WIPEOUT", entity.dxfattribs())
)
if len(entity.boundary_path):
self.add_list_source_code(
(v for v in entity.boundary_path), # just x, y axis
prolog="e.set_boundary_path([",
epilog="])",
)
def _mesh(self, entity: Mesh):
self.add_source_code_lines(
self.api_call("add_mesh(", [], entity.dxfattribs())
)
if len(entity.vertices):
self.add_list_source_code(
entity.vertices, prolog="e.vertices = [", epilog="]"
)
if len(entity.edges):
# array.array -> tuple
self.add_list_source_code(
(tuple(e) for e in entity.edges),
prolog="e.edges = [",
epilog="]",
)
if len(entity.faces):
# array.array -> tuple
self.add_list_source_code(
(tuple(f) for f in entity.faces),
prolog="e.faces = [",
epilog="]",
)
if len(entity.creases):
self.add_list_source_code(
entity.creases, prolog="e.creases = [", epilog="]"
)
def _hatch(self, entity: Hatch):
dxfattribs = entity.dxfattribs()
dxfattribs["associative"] = 0 # associative hatch not supported
self.add_source_code_lines(
self.api_call("add_hatch(", ["color"], dxfattribs)
)
self._polygon(entity)
def _mpolygon(self, entity: MPolygon):
dxfattribs = entity.dxfattribs()
self.add_source_code_lines(
self.api_call("add_mpolygon(", ["color"], dxfattribs)
)
if entity.dxf.solid_fill:
self.add_source_code_line(
f"e.set_solid_fill(color={entity.dxf.fill_color})\n"
)
self._polygon(entity)
def _polygon(self, entity: DXFPolygon):
add_line = self.add_source_code_line
if len(entity.seeds):
add_line(f"e.set_seed_points({entity.seeds})")
if entity.pattern:
self.add_list_source_code(
map(str, entity.pattern.lines),
prolog="e.set_pattern_definition([",
epilog="])",
)
self.add_source_code_line("e.dxf.solid_fill = 0")
arg = " {}={},"
if entity.gradient is not None:
g = entity.gradient
add_line("e.set_gradient(")
add_line(arg.format("color1", str(g.color1)))
add_line(arg.format("color2", str(g.color2)))
add_line(arg.format("rotation", g.rotation))
add_line(arg.format("centered", g.centered))
add_line(arg.format("one_color", g.one_color))
add_line(arg.format("name", json.dumps(g.name)))
add_line(")")
for count, path in enumerate(entity.paths, start=1):
if path.type == BoundaryPathType.POLYLINE:
add_line("# {}. polyline path".format(count))
self.add_list_source_code(
path.vertices,
prolog="e.paths.add_polyline_path([",
epilog=" ],",
)
add_line(arg.format("is_closed", str(path.is_closed)))
add_line(arg.format("flags", str(path.path_type_flags)))
add_line(")")
else: # EdgePath
add_line(
f"# {count}. edge path: associative hatch not supported"
)
add_line(
f"ep = e.paths.add_edge_path(flags={path.path_type_flags})"
)
for edge in path.edges:
if edge.type == EdgeType.LINE:
add_line(f"ep.add_line({edge.start}, {str(edge.end)})")
elif edge.type == EdgeType.ARC:
# Start- and end angles are always stored in ccw
# orientation:
add_line("ep.add_arc(")
add_line(arg.format("center", str(edge.center)))
add_line(arg.format("radius", edge.radius))
add_line(arg.format("start_angle", edge.start_angle))
add_line(arg.format("end_angle", edge.end_angle))
add_line(arg.format("ccw", edge.ccw))
add_line(")")
elif edge.type == EdgeType.ELLIPSE:
# Start- and end params are always stored in ccw
# orientation:
add_line("ep.add_ellipse(")
add_line(arg.format("center", str(edge.center)))
add_line(arg.format("major_axis", str(edge.major_axis)))
add_line(arg.format("ratio", edge.ratio))
add_line(arg.format("start_angle", edge.start_angle))
add_line(arg.format("end_angle", edge.end_angle))
add_line(arg.format("ccw", edge.ccw))
add_line(")")
elif edge.type == EdgeType.SPLINE:
add_line("ep.add_spline(")
if edge.fit_points:
add_line(
arg.format(
"fit_points",
str([fp for fp in edge.fit_points]),
)
)
if edge.control_points:
add_line(
arg.format(
"control_points",
str([cp for cp in edge.control_points]),
)
)
if edge.knot_values:
add_line(
arg.format("knot_values", str(edge.knot_values))
)
if edge.weights:
add_line(arg.format("weights", str(edge.weights)))
add_line(arg.format("degree", edge.degree))
add_line(arg.format("periodic", edge.periodic))
if edge.start_tangent is not None:
add_line(
arg.format(
"start_tangent", str(edge.start_tangent)
)
)
if edge.end_tangent is not None:
add_line(
arg.format("end_tangent", str(edge.end_tangent))
)
add_line(")")
# simple table entries
def _layer(self, layer: DXFEntity):
self.add_source_code_lines(
self.new_table_entry("LAYER", layer.dxfattribs())
)
def _ltype(self, ltype: Linetype):
self.add_import_statement("from ezdxf.lldxf.tags import Tags")
self.add_import_statement("from ezdxf.lldxf.types import dxftag")
self.add_import_statement(
"from ezdxf.entities.ltype import LinetypePattern"
)
self.add_source_code_lines(
self.new_table_entry("LTYPE", ltype.dxfattribs())
)
self.add_tags_source_code(
ltype.pattern_tags.tags,
prolog="tags = Tags([",
epilog="])",
indent=4,
)
self.add_source_code_line(" t.pattern_tags = LinetypePattern(tags)")
def _style(self, style: DXFEntity):
self.add_source_code_lines(
self.new_table_entry("STYLE", style.dxfattribs())
)
def _dimstyle(self, dimstyle: DXFEntity):
self.add_source_code_lines(
self.new_table_entry("DIMSTYLE", dimstyle.dxfattribs())
)
def _appid(self, appid: DXFEntity):
self.add_source_code_lines(
self.new_table_entry("APPID", appid.dxfattribs())
)
@@ -0,0 +1,721 @@
# Copyright (c) 2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Sequence,
Iterable,
Optional,
Callable,
Union,
)
import abc
import copy
from dataclasses import dataclass
import json
import random
import time
# example usage:
# examples\addons\optimize\bin_packing_forms.py
# examples\addons\optimize\tsp.py
class DNA(abc.ABC):
"""Abstract DNA class."""
fitness: Optional[float] = None
_data: list
@abc.abstractmethod
def reset(self, values: Iterable):
...
@property
@abc.abstractmethod
def is_valid(self) -> bool:
...
def copy(self):
return copy.deepcopy(self)
def _taint(self):
self.fitness = None
def __repr__(self):
return f"{self.__class__.__name__}({str(self._data)})"
def __eq__(self, other):
assert isinstance(other, self.__class__)
return self._data == other._data
def __len__(self):
return len(self._data)
def __getitem__(self, item):
return self._data.__getitem__(item)
def __setitem__(self, key, value):
self._data.__setitem__(key, value)
self._taint()
def __iter__(self):
return iter(self._data)
@abc.abstractmethod
def flip_mutate_at(self, index: int) -> None:
...
def dna_fitness(dna: DNA) -> float:
return dna.fitness # type: ignore
class Mutate(abc.ABC):
"""Abstract mutation."""
@abc.abstractmethod
def mutate(self, dna: DNA, rate: float):
...
class FlipMutate(Mutate):
"""Flip one bit mutation."""
def mutate(self, dna: DNA, rate: float):
for index in range(len(dna)):
if random.random() < rate:
dna.flip_mutate_at(index)
class NeighborSwapMutate(Mutate):
"""Swap two neighbors mutation."""
def mutate(self, dna: DNA, rate: float):
for index in range(len(dna)):
if random.random() < rate:
i2 = index - 1
tmp = dna[i2]
dna[i2] = dna[index]
dna[index] = tmp
class RandomSwapMutate(Mutate):
"""Swap two random places mutation."""
def mutate(self, dna: DNA, rate: float):
length = len(dna)
for index in range(length):
if random.random() < rate:
i2 = random.randrange(0, length)
if i2 == index:
i2 -= 1
tmp = dna[i2]
dna[i2] = dna[index]
dna[index] = tmp
class ReverseMutate(Mutate):
"""Reverse some consecutive bits mutation."""
def __init__(self, bits: int = 3):
self._bits = int(max(bits, 1))
def mutate(self, dna: DNA, rate: float):
length = len(dna)
if random.random() < rate * (
length / self._bits
): # applied to all bits at ones
i1 = random.randrange(length - self._bits)
i2 = i1 + self._bits
bits = dna[i1:i2]
dna[i1:i2] = reversed(bits)
class ScrambleMutate(Mutate):
"""Scramble some consecutive bits mutation."""
def __init__(self, bits: int = 3):
self._bits = int(max(bits, 1))
def mutate(self, dna: DNA, rate: float):
length = len(dna)
if random.random() < rate * (
length / self._bits
): # applied to all bits at ones
i1 = random.randrange(length - self._bits)
i2 = i1 + self._bits
bits = dna[i1:i2]
random.shuffle(bits)
dna[i1:i2] = bits
class Mate(abc.ABC):
"""Abstract recombination."""
@abc.abstractmethod
def recombine(self, dna1: DNA, dna2: DNA):
pass
class Mate1pCX(Mate):
"""One point crossover recombination."""
def recombine(self, dna1: DNA, dna2: DNA):
length = len(dna1)
index = random.randrange(0, length)
recombine_dna_2pcx(dna1, dna2, index, length)
class Mate2pCX(Mate):
"""Two point crossover recombination."""
def recombine(self, dna1: DNA, dna2: DNA):
length = len(dna1)
i1 = random.randrange(0, length)
i2 = random.randrange(0, length)
if i1 > i2:
i1, i2 = i2, i1
recombine_dna_2pcx(dna1, dna2, i1, i2)
class MateUniformCX(Mate):
"""Uniform recombination."""
def recombine(self, dna1: DNA, dna2: DNA):
for index in range(len(dna1)):
if random.random() > 0.5:
tmp = dna1[index]
dna1[index] = dna2[index]
dna2[index] = tmp
class MateOrderedCX(Mate):
"""Recombination class for ordered DNA like UniqueIntDNA()."""
def recombine(self, dna1: DNA, dna2: DNA):
length = len(dna1)
i1 = random.randrange(0, length)
i2 = random.randrange(0, length)
if i1 > i2:
i1, i2 = i2, i1
recombine_dna_ocx1(dna1, dna2, i1, i2)
def recombine_dna_2pcx(dna1: DNA, dna2: DNA, i1: int, i2: int) -> None:
"""Two point crossover."""
part1 = dna1[i1:i2]
part2 = dna2[i1:i2]
dna1[i1:i2] = part2
dna2[i1:i2] = part1
def recombine_dna_ocx1(dna1: DNA, dna2: DNA, i1: int, i2: int) -> None:
"""Ordered crossover."""
copy1 = dna1.copy()
replace_dna_ocx1(dna1, dna2, i1, i2)
replace_dna_ocx1(dna2, copy1, i1, i2)
def replace_dna_ocx1(dna1: DNA, dna2: DNA, i1: int, i2: int) -> None:
"""Replace a part in dna1 by dna2 and preserve order of remaining values in
dna1.
"""
old = dna1.copy()
new = dna2[i1:i2]
dna1[i1:i2] = new
index = 0
new_set = set(new)
for value in old:
if value in new_set:
continue
if index == i1:
index = i2
dna1[index] = value
index += 1
class FloatDNA(DNA):
"""Arbitrary float numbers in the range [0, 1]."""
__slots__ = ("_data", "fitness")
def __init__(self, values: Iterable[float]):
self._data: list[float] = list(values)
self._check_valid_data()
self.fitness: Optional[float] = None
@classmethod
def random(cls, length: int) -> FloatDNA:
return cls((random.random() for _ in range(length)))
@classmethod
def n_random(cls, n: int, length: int) -> list[FloatDNA]:
return [cls.random(length) for _ in range(n)]
@property
def is_valid(self) -> bool:
return all(0.0 <= v <= 1.0 for v in self._data)
def _check_valid_data(self):
if not self.is_valid:
raise ValueError("data value out of range")
def __str__(self):
if self.fitness is None:
fitness = ", fitness=None"
else:
fitness = f", fitness={self.fitness:.4f}"
return f"{str([round(v, 4) for v in self._data])}{fitness}"
def reset(self, values: Iterable[float]):
self._data = list(values)
self._check_valid_data()
self._taint()
def flip_mutate_at(self, index: int) -> None:
self._data[index] = 1.0 - self._data[index] # flip pick location
class BitDNA(DNA):
"""One bit DNA."""
__slots__ = ("_data", "fitness")
def __init__(self, values: Iterable):
self._data: list[bool] = list(bool(v) for v in values)
self.fitness: Optional[float] = None
@property
def is_valid(self) -> bool:
return True # everything can be evaluated to True/False
@classmethod
def random(cls, length: int) -> BitDNA:
return cls(bool(random.randint(0, 1)) for _ in range(length))
@classmethod
def n_random(cls, n: int, length: int) -> list[BitDNA]:
return [cls.random(length) for _ in range(n)]
def __str__(self):
if self.fitness is None:
fitness = ", fitness=None"
else:
fitness = f", fitness={self.fitness:.4f}"
return f"{str([int(v) for v in self._data])}{fitness}"
def reset(self, values: Iterable) -> None:
self._data = list(bool(v) for v in values)
self._taint()
def flip_mutate_at(self, index: int) -> None:
self._data[index] = not self._data[index]
class UniqueIntDNA(DNA):
"""Unique integer values in the range from 0 to length-1.
E.g. UniqueIntDNA(10) = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Requires MateOrderedCX() as recombination class to preserve order and
validity after DNA recombination.
Requires mutation by swapping like SwapRandom(), SwapNeighbors(),
ReversMutate() or ScrambleMutate()
"""
__slots__ = ("_data", "fitness")
def __init__(self, values: Union[int, Iterable]):
self._data: list[int]
if isinstance(values, int):
self._data = list(range(values))
else:
self._data = [int(v) for v in values]
if not self.is_valid:
raise TypeError(self._data)
self.fitness: Optional[float] = None
@classmethod
def random(cls, length: int) -> UniqueIntDNA:
dna = cls(length)
random.shuffle(dna._data)
return dna
@classmethod
def n_random(cls, n: int, length: int) -> list[UniqueIntDNA]:
return [cls.random(length) for _ in range(n)]
@property
def is_valid(self) -> bool:
return len(set(self._data)) == len(self._data)
def __str__(self):
if self.fitness is None:
fitness = ", fitness=None"
else:
fitness = f", fitness={self.fitness:.4f}"
return f"{str([int(v) for v in self._data])}{fitness}"
def reset(self, values: Iterable) -> None:
self._data = list(int(v) for v in values)
self._taint()
def flip_mutate_at(self, index: int) -> None:
raise TypeError("flip mutation not supported")
class IntegerDNA(DNA):
"""Integer values in the range from 0 to max_ - 1.
E.g. IntegerDNA([0, 1, 2, 3, 4, 0, 1, 2, 3, 4], 5)
"""
__slots__ = ("_data", "fitness")
def __init__(self, values: Iterable[int], max_: int):
self._max = int(max_)
self._data: list[int] = list(values)
if not self.is_valid:
raise TypeError(self._data)
self.fitness: Optional[float] = None
@classmethod
def random(cls, length: int, max_: int) -> IntegerDNA:
imax = int(max_)
return cls((random.randrange(0, imax) for _ in range(length)), imax)
@classmethod
def n_random(cls, n: int, length: int, max_: int) -> list[IntegerDNA]:
return [cls.random(length, max_) for _ in range(n)]
@property
def is_valid(self) -> bool:
return all(0 <= v < self._max for v in self._data)
def __repr__(self):
return f"{self.__class__.__name__}({str(self._data)}, {self._max})"
def __str__(self):
if self.fitness is None:
fitness = ", fitness=None"
else:
fitness = f", fitness={self.fitness:.4f}"
return f"{str([int(v) for v in self._data])}{fitness}"
def reset(self, values: Iterable) -> None:
self._data = list(int(v) for v in values)
self._taint()
def flip_mutate_at(self, index: int) -> None:
self._data[index] = self._max - self._data[index] - 1
class Selection(abc.ABC):
"""Abstract selection class."""
@abc.abstractmethod
def pick(self, count: int) -> Iterable[DNA]:
...
@abc.abstractmethod
def reset(self, candidates: Iterable[DNA]):
...
class Evaluator(abc.ABC):
"""Abstract evaluation class."""
@abc.abstractmethod
def evaluate(self, dna: DNA) -> float:
...
class Log:
@dataclass
class Entry:
runtime: float
fitness: float
avg_fitness: float
def __init__(self) -> None:
self.entries: list[Log.Entry] = []
def add(self, runtime: float, fitness: float, avg_fitness: float) -> None:
self.entries.append(Log.Entry(runtime, fitness, avg_fitness))
def dump(self, filename: str) -> None:
data = [(e.runtime, e.fitness, e.avg_fitness) for e in self.entries]
with open(filename, "wt") as fp:
json.dump(data, fp, indent=4)
@classmethod
def load(cls, filename: str) -> Log:
with open(filename, "rt") as fp:
data = json.load(fp)
log = Log()
for runtime, fitness, avg_fitness in data:
log.add(runtime, fitness, avg_fitness)
return log
class HallOfFame:
def __init__(self, count):
self.count = count
self._unique_entries = dict()
def __iter__(self):
return (
self._unique_entries[k] for k in self._sorted_keys()[: self.count]
)
def _sorted_keys(self):
return sorted(self._unique_entries.keys(), reverse=True)
def add(self, dna: DNA):
assert dna.fitness is not None
self._unique_entries[dna.fitness] = dna
def get(self, count: int) -> list[DNA]:
entries = self._unique_entries
keys = self._sorted_keys()
return [entries[k] for k in keys[: min(count, self.count)]]
def purge(self):
if len(self._unique_entries) <= self.count:
return
entries = self._unique_entries
keys = self._sorted_keys()
self._unique_entries = {k: entries[k] for k in keys[: self.count]}
def threshold_filter(
candidates: Sequence[DNA], best_fitness: float, threshold: float
) -> Iterable[DNA]:
if best_fitness <= 0.0:
minimum = min(candidates, key=dna_fitness)
min_value = (1.0 - threshold) * minimum.fitness # type: ignore
else:
min_value = best_fitness * threshold
return (c for c in candidates if c.fitness > min_value) # type: ignore
class GeneticOptimizer:
"""A genetic algorithm (GA) is a meta-heuristic inspired by the process of
natural selection. Genetic algorithms are commonly used to generate
high-quality solutions to optimization and search problems by relying on
biologically inspired operators such as mutation, crossover and selection.
Source: https://en.wikipedia.org/wiki/Genetic_algorithm
This implementation searches always for the maximum fitness, fitness
comparisons are always done by the "greater than" operator (">").
The algorithm supports negative values to search for the minimum fitness
(e.g. Travelling Salesmen Problem: -900 > -1000). Reset the start fitness
by the method :meth:`reset_fitness` accordingly::
optimizer.reset_fitness(-1e99)
"""
def __init__(
self,
evaluator: Evaluator,
max_generations: int,
max_fitness: float = 1.0,
):
if max_generations < 1:
raise ValueError("requires max_generations > 0")
# data:
self.name = "GeneticOptimizer"
self.log = Log()
self.candidates: list[DNA] = []
# core components:
self.evaluator: Evaluator = evaluator
self.selection: Selection = RouletteSelection()
self.mate: Mate = Mate2pCX()
self.mutation = FlipMutate()
# options:
self.max_generations = int(max_generations)
self.max_fitness: float = float(max_fitness)
self.max_runtime: float = 1e99
self.max_stagnation = 100
self.crossover_rate = 0.70
self.mutation_rate = 0.01
self.elitism: int = 2
# percentage (0.1 = 10%) of candidates with least fitness to ignore in
# next generation
self.threshold: float = 0.0
# state of last (current) generation:
self.generation: int = 0
self.start_time = 0.0
self.runtime: float = 0.0
self.best_dna: DNA = BitDNA([])
self.best_fitness: float = 0.0
self.stagnation: int = 0 # generations without improvement
self.hall_of_fame = HallOfFame(10)
def reset_fitness(self, value: float) -> None:
self.best_fitness = float(value)
@property
def is_executed(self) -> bool:
return bool(self.generation)
@property
def count(self) -> int:
return len(self.candidates)
def add_candidates(self, dna: Iterable[DNA]):
if not self.is_executed:
self.candidates.extend(dna)
else:
raise TypeError("already executed")
def execute(
self,
feedback: Optional[Callable[[GeneticOptimizer], bool]] = None,
interval: float = 1.0,
) -> None:
if self.is_executed:
raise TypeError("can only run once")
if not self.candidates:
print("no DNA defined!")
t0 = time.perf_counter()
self.start_time = t0
for self.generation in range(1, self.max_generations + 1):
self.measure_fitness()
t1 = time.perf_counter()
self.runtime = t1 - self.start_time
if (
self.best_fitness >= self.max_fitness
or self.runtime >= self.max_runtime
or self.stagnation >= self.max_stagnation
):
break
if feedback and t1 - t0 > interval:
if feedback(self): # stop if feedback() returns True
break
t0 = t1
self.next_generation()
def measure_fitness(self) -> None:
self.stagnation += 1
fitness_sum: float = 0.0
for dna in self.candidates:
if dna.fitness is not None:
fitness_sum += dna.fitness
continue
fitness = self.evaluator.evaluate(dna)
dna.fitness = fitness
fitness_sum += fitness
self.hall_of_fame.add(dna)
if fitness > self.best_fitness:
self.best_fitness = fitness
self.best_dna = dna
self.stagnation = 0
self.hall_of_fame.purge()
try:
avg_fitness = fitness_sum / len(self.candidates)
except ZeroDivisionError:
avg_fitness = 0.0
self.log.add(
time.perf_counter() - self.start_time,
self.best_fitness,
avg_fitness,
)
def next_generation(self) -> None:
count = len(self.candidates)
candidates: list[DNA] = []
selector = self.selection
selector.reset(self.filter_threshold(self.candidates))
if self.elitism > 0:
candidates.extend(self.hall_of_fame.get(self.elitism))
while len(candidates) < count:
dna1, dna2 = selector.pick(2)
dna1 = dna1.copy()
dna2 = dna2.copy()
self.recombine(dna1, dna2)
self.mutate(dna1, dna2)
candidates.append(dna1)
candidates.append(dna2)
self.candidates = candidates
def filter_threshold(self, candidates: Sequence[DNA]) -> Iterable[DNA]:
if self.threshold > 0.0:
return threshold_filter(
candidates, self.best_fitness, self.threshold
)
else:
return candidates
def recombine(self, dna1: DNA, dna2: DNA):
if random.random() < self.crossover_rate:
self.mate.recombine(dna1, dna2)
def mutate(self, dna1: DNA, dna2: DNA):
self.mutation.mutate(dna1, self.mutation_rate)
self.mutation.mutate(dna2, self.mutation_rate)
def conv_negative_weights(weights: Iterable[float]) -> Iterable[float]:
# random.choices does not accept negative values: -100 -> 1/100, -10 -> 1/10
return (0.0 if w == 0.0 else 1.0 / abs(w) for w in weights)
class RouletteSelection(Selection):
"""Selection by fitness values."""
def __init__(self, negative_values: bool = False) -> None:
self._candidates: list[DNA] = []
self._weights: list[float] = []
self._negative_values = bool(negative_values)
def reset(self, candidates: Iterable[DNA]):
# dna.fitness is not None here!
self._candidates = list(candidates)
if self._negative_values:
self._weights = list(
conv_negative_weights(dna.fitness for dna in self._candidates) # type: ignore
)
else:
self._weights = [dna.fitness for dna in self._candidates] # type: ignore
def pick(self, count: int) -> Iterable[DNA]:
return random.choices(self._candidates, self._weights, k=count)
class RankBasedSelection(RouletteSelection):
"""Selection by rank of fitness."""
def reset(self, candidates: Iterable[DNA]):
# dna.fitness is not None here!
self._candidates = list(candidates)
self._candidates.sort(key=dna_fitness)
# weight of best_fitness == len(strands)
# and decreases until 1 for the least fitness
self._weights = list(range(1, len(self._candidates) + 1))
class TournamentSelection(Selection):
"""Selection by choosing the best of a certain count of candidates."""
def __init__(self, candidates: int):
self._candidates: list[DNA] = []
self.candidates = candidates
def reset(self, candidates: Iterable[DNA]):
self._candidates = list(candidates)
def pick(self, count: int) -> Iterable[DNA]:
for _ in range(count):
values = [
random.choice(self._candidates) for _ in range(self.candidates)
]
values.sort(key=dna_fitness)
yield values[-1]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
# Copyright (c) 2022, Manfred Moitzi
# License: MIT License
# This add-on was created to solve this problem: https://github.com/mozman/ezdxf/discussions/789
from __future__ import annotations
from typing import TextIO
import os
import io
import ezdxf
from ezdxf.document import Drawing
from ezdxf.layouts import Modelspace
from ezdxf.lldxf.tagwriter import TagWriter, AbstractTagWriter
__all__ = ["export_file", "export_stream"]
def export_file(doc: Drawing, filename: str | os.PathLike) -> None:
"""Exports the specified DXF R12 document, which should contain content conforming
to the ASTM-D6673-10 standard, in a special way so that Gerber Technology applications
can parse it by their low-quality DXF parser.
"""
fp = io.open(filename, mode="wt", encoding="ascii", errors="dxfreplace")
export_stream(doc, fp)
def export_stream(doc: Drawing, stream: TextIO) -> None:
"""Exports the specified DXF R12 document into a `stream` object."""
if doc.dxfversion != ezdxf.const.DXF12:
raise ezdxf.DXFVersionError("only DXF R12 format is supported")
tagwriter = TagWriter(stream, write_handles=False, dxfversion=ezdxf.const.DXF12)
_export_sections(doc, tagwriter)
def _export_sections(doc: Drawing, tagwriter: AbstractTagWriter) -> None:
_export_header(tagwriter)
_export_blocks(doc, tagwriter)
_export_entities(doc.modelspace(), tagwriter)
def _export_header(tagwriter: AbstractTagWriter) -> None:
# export empty header section
tagwriter.write_str(" 0\nSECTION\n 2\nHEADER\n")
tagwriter.write_tag2(0, "ENDSEC")
def _export_blocks(doc: Drawing, tagwriter: AbstractTagWriter) -> None:
# This is the important part:
#
# Gerber Technology applications have a bad DXF parser which do not accept DXF
# files that contain blocks without ASTM-D6673-10 content, such as the standard
# $MODEL_SPACE and $PAPER_SPACE block definitions.
#
# This is annoying but the presence of these blocks is NOT mandatory for
# the DXF R12 standard.
#
tagwriter.write_str(" 0\nSECTION\n 2\nBLOCKS\n")
for block_record in doc.block_records:
# export only BLOCK definitions, ignore LAYOUT definition blocks
if block_record.is_block_layout:
block_record.export_block_definition(tagwriter)
tagwriter.write_tag2(0, "ENDSEC")
def _export_entities(msp: Modelspace, tagwriter: AbstractTagWriter) -> None:
tagwriter.write_str(" 0\nSECTION\n 2\nENTITIES\n")
msp.entity_space.export_dxf(tagwriter)
tagwriter.write_tag2(0, "ENDSEC")
tagwriter.write_tag2(0, "EOF")
@@ -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
@@ -0,0 +1,664 @@
# Purpose: Import data from another DXF document
# Copyright (c) 2013-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, cast, Union, Optional
import logging
from ezdxf.lldxf import const
from ezdxf.render.arrows import ARROWS
from ezdxf.entities import (
DXFEntity,
DXFGraphic,
Hatch,
Polyline,
DimStyle,
Dimension,
Viewport,
Linetype,
Insert,
)
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.layouts import BaseLayout, Layout
logger = logging.getLogger("ezdxf")
IMPORT_TABLES = ["linetypes", "layers", "styles", "dimstyles"]
IMPORT_ENTITIES = {
"LINE",
"POINT",
"CIRCLE",
"ARC",
"TEXT",
"SOLID",
"TRACE",
"3DFACE",
"SHAPE",
"POLYLINE",
"ATTRIB",
"INSERT",
"ELLIPSE",
"MTEXT",
"LWPOLYLINE",
"SPLINE",
"HATCH",
"MESH",
"XLINE",
"RAY",
"ATTDEF",
"DIMENSION",
"LEADER", # dimension style override not supported!
"VIEWPORT",
}
class Importer:
"""
The :class:`Importer` class is central element for importing data from
other DXF documents.
Args:
source: source :class:`~ezdxf.drawing.Drawing`
target: target :class:`~ezdxf.drawing.Drawing`
Attributes:
source: source DXF document
target: target DXF document
used_layers: Set of used layer names as string, AutoCAD accepts layer
names without a LAYER table entry.
used_linetypes: Set of used linetype names as string, these linetypes
require a TABLE entry or AutoCAD will crash.
used_styles: Set of used text style names, these text styles require
a TABLE entry or AutoCAD will crash.
used_dimstyles: Set of used dimension style names, these dimension
styles require a TABLE entry or AutoCAD will crash.
"""
def __init__(self, source: Drawing, target: Drawing):
self.source: Drawing = source
self.target: Drawing = target
self.used_layers: set[str] = set()
self.used_linetypes: set[str] = set()
self.used_styles: set[str] = set()
self.used_shape_files: set[str] = set() # style entry without a name!
self.used_dimstyles: set[str] = set()
self.used_arrows: set[str] = set()
self.handle_mapping: dict[str, str] = dict() # old_handle: new_handle
# collects all imported INSERT entities, for later name resolving.
self.imported_inserts: list[DXFEntity] = list() # imported inserts
# collects all imported block names and their assigned new name
# imported_block[original_name] = new_name
self.imported_blocks: dict[str, str] = dict()
self._default_plotstyle_handle = target.plotstyles["Normal"].dxf.handle
self._default_material_handle = target.materials["Global"].dxf.handle
def _add_used_resources(self, entity: DXFEntity) -> None:
"""Register used resources."""
self.used_layers.add(entity.get_dxf_attrib("layer", "0"))
self.used_linetypes.add(entity.get_dxf_attrib("linetype", "BYLAYER"))
if entity.is_supported_dxf_attrib("style"):
self.used_styles.add(entity.get_dxf_attrib("style", "Standard"))
if entity.is_supported_dxf_attrib("dimstyle"):
self.used_dimstyles.add(entity.get_dxf_attrib("dimstyle", "Standard"))
def _add_dimstyle_resources(self, dimstyle: DimStyle) -> None:
self.used_styles.add(dimstyle.get_dxf_attrib("dimtxsty", "Standard"))
self.used_linetypes.add(dimstyle.get_dxf_attrib("dimltype", "BYLAYER"))
self.used_linetypes.add(dimstyle.get_dxf_attrib("dimltex1", "BYLAYER"))
self.used_linetypes.add(dimstyle.get_dxf_attrib("dimltex2", "BYLAYER"))
self.used_arrows.add(dimstyle.get_dxf_attrib("dimblk", ""))
self.used_arrows.add(dimstyle.get_dxf_attrib("dimblk1", ""))
self.used_arrows.add(dimstyle.get_dxf_attrib("dimblk2", ""))
self.used_arrows.add(dimstyle.get_dxf_attrib("dimldrblk", ""))
def _add_linetype_resources(self, linetype: Linetype) -> None:
if not linetype.pattern_tags.is_complex_type():
return
style_handle = linetype.pattern_tags.get_style_handle()
style = self.source.entitydb.get(style_handle)
if style is None:
return
if style.dxf.name == "":
# Shape file entries have no name!
self.used_shape_files.add(style.dxf.font)
else:
self.used_styles.add(style.dxf.name)
def import_tables(
self, table_names: Union[str, Iterable[str]] = "*", replace=False
) -> None:
"""Import DXF tables from the source document into the target document.
Args:
table_names: iterable of tables names as strings, or a single table
name as string or "*" for all supported tables
replace: ``True`` to replace already existing table entries else
ignore existing entries
Raises:
TypeError: unsupported table type
"""
if isinstance(table_names, str):
if table_names == "*": # import all supported tables
table_names = IMPORT_TABLES
else: # import one specific table
table_names = (table_names,)
for table_name in table_names:
self.import_table(table_name, entries="*", replace=replace)
def import_table(
self, name: str, entries: Union[str, Iterable[str]] = "*", replace=False
) -> None:
"""
Import specific table entries from the source document into the
target document.
Args:
name: valid table names are "layers", "linetypes" and "styles"
entries: Iterable of table names as strings, or a single table name
or "*" for all table entries
replace: ``True`` to replace the already existing table entry else
ignore existing entries
Raises:
TypeError: unsupported table type
"""
if name not in IMPORT_TABLES:
raise TypeError(f'Table "{name}" import not supported.')
source_table = getattr(self.source.tables, name)
target_table = getattr(self.target.tables, name)
if isinstance(entries, str):
if entries == "*": # import all table entries
entries = (entry.dxf.name for entry in source_table)
else: # import just one table entry
entries = (entries,)
for entry_name in entries:
try:
table_entry = source_table.get(entry_name)
except const.DXFTableEntryError:
logger.warning(
f'Required table entry "{entry_name}" in table f{name} '
f"not found."
)
continue
entry_name = table_entry.dxf.name
if entry_name in target_table:
if replace:
logger.debug(
f'Replacing already existing entry "{entry_name}" '
f"of {name} table."
)
target_table.remove(table_entry.dxf.name)
else:
logger.debug(
f'Discarding already existing entry "{entry_name}" '
f"of {name} table."
)
continue
if name == "layers":
self.used_linetypes.add(
table_entry.get_dxf_attrib("linetype", "Continuous")
)
elif name == "dimstyles":
self._add_dimstyle_resources(table_entry)
elif name == "linetypes":
self._add_linetype_resources(table_entry)
# Duplicate table entry:
new_table_entry = self._duplicate_table_entry(table_entry)
target_table.add_entry(new_table_entry)
# Register resource handles for mapping:
self.handle_mapping[table_entry.dxf.handle] = new_table_entry.dxf.handle
def import_shape_files(self, fonts: set[str]) -> None:
"""Import shape file table entries from the source document into the
target document.
Shape file entries are stored in the styles table but without a name.
"""
for font in fonts:
table_entry = self.source.styles.find_shx(font)
# copy is not necessary, just create a new entry:
new_table_entry = self.target.styles.get_shx(font)
if table_entry:
# Register resource handles for mapping:
self.handle_mapping[table_entry.dxf.handle] = new_table_entry.dxf.handle
else:
logger.warning(f'Required shape file entry "{font}" not found.')
def _set_table_entry_dxf_attribs(self, entity: DXFEntity) -> None:
entity.doc = self.target
if entity.dxf.hasattr("plotstyle_handle"):
entity.dxf.plotstyle_handle = self._default_plotstyle_handle
if entity.dxf.hasattr("material_handle"):
entity.dxf.material_handle = self._default_material_handle
def _duplicate_table_entry(self, entry: DXFEntity) -> DXFEntity:
# duplicate table entry
new_entry = new_clean_entity(entry)
self._set_table_entry_dxf_attribs(entry)
# create a new handle and add entity to target entity database
self.target.entitydb.add(new_entry)
return new_entry
def import_entity(
self, entity: DXFEntity, target_layout: Optional[BaseLayout] = None
) -> None:
"""
Imports a single DXF `entity` into `target_layout` or the modelspace
of the target document, if `target_layout` is ``None``.
Args:
entity: DXF entity to import
target_layout: any layout (modelspace, paperspace or block) from
the target document
Raises:
DXFStructureError: `target_layout` is not a layout of target document
"""
def set_dxf_attribs(e):
e.doc = self.target
# remove invalid resources
e.dxf.discard("plotstyle_handle")
e.dxf.discard("material_handle")
e.dxf.discard("visualstyle_handle")
if target_layout is None:
target_layout = self.target.modelspace()
elif target_layout.doc != self.target:
raise const.DXFStructureError(
"Target layout has to be a layout or block from the target " "document."
)
dxftype = entity.dxftype()
if dxftype not in IMPORT_ENTITIES:
logger.debug(f"Import of {str(entity)} not supported")
return
self._add_used_resources(entity)
try:
new_entity = cast(DXFGraphic, new_clean_entity(entity))
except const.DXFTypeError:
logger.debug(f"Copying for DXF type {dxftype} not supported.")
return
set_dxf_attribs(new_entity)
self.target.entitydb.add(new_entity)
target_layout.add_entity(new_entity)
try: # additional processing
getattr(self, "_import_" + dxftype.lower())(new_entity)
except AttributeError:
pass
def _import_insert(self, insert: Insert):
self.imported_inserts.append(insert)
# remove all possible source document dependencies from sub entities
for attrib in insert.attribs:
remove_dependencies(attrib)
def _import_polyline(self, polyline: Polyline):
# remove all possible source document dependencies from sub entities
for vertex in polyline.vertices:
remove_dependencies(vertex)
def _import_hatch(self, hatch: Hatch):
hatch.dxf.discard("associative")
def _import_viewport(self, viewport: Viewport):
viewport.dxf.discard("sun_handle")
viewport.dxf.discard("clipping_boundary_handle")
viewport.dxf.discard("ucs_handle")
viewport.dxf.discard("ucs_base_handle")
viewport.dxf.discard("background_handle")
viewport.dxf.discard("shade_plot_handle")
viewport.dxf.discard("visual_style_handle")
viewport.dxf.discard("ref_vp_object_1")
viewport.dxf.discard("ref_vp_object_2")
viewport.dxf.discard("ref_vp_object_3")
viewport.dxf.discard("ref_vp_object_4")
def _import_dimension(self, dimension: Dimension):
if dimension.virtual_block_content:
for entity in dimension.virtual_block_content:
if isinstance(entity, Insert): # import arrow blocks
self.import_block(entity.dxf.name, rename=False)
self._add_used_resources(entity)
else:
logger.error("The required geometry block for DIMENSION is not defined.")
def import_entities(
self,
entities: Iterable[DXFEntity],
target_layout: Optional[BaseLayout] = None,
) -> None:
"""Import all `entities` into `target_layout` or the modelspace of the
target document, if `target_layout` is ``None``.
Args:
entities: Iterable of DXF entities
target_layout: any layout (modelspace, paperspace or block) from
the target document
Raises:
DXFStructureError: `target_layout` is not a layout of target document
"""
for entity in entities:
self.import_entity(entity, target_layout)
def import_modelspace(self, target_layout: Optional[BaseLayout] = None) -> None:
"""Import all entities from source modelspace into `target_layout` or
the modelspace of the target document, if `target_layout` is ``None``.
Args:
target_layout: any layout (modelspace, paperspace or block) from
the target document
Raises:
DXFStructureError: `target_layout` is not a layout of target document
"""
self.import_entities(self.source.modelspace(), target_layout=target_layout)
def recreate_source_layout(self, name: str) -> Layout:
"""Recreate source paperspace layout `name` in the target document.
The layout will be renamed if `name` already exist in the target
document. Returns target modelspace for layout name "Model".
Args:
name: layout name as string
Raises:
KeyError: if source layout `name` not exist
"""
def get_target_name():
tname = name
base_name = name
count = 1
while tname in self.target.layouts:
tname = base_name + str(count)
count += 1
return tname
def clear(dxfattribs: dict) -> dict:
def discard(name: str):
try:
del dxfattribs[name]
except KeyError:
pass
discard("handle")
discard("owner")
discard("taborder")
discard("shade_plot_handle")
discard("block_record_handle")
discard("viewport_handle")
discard("ucs_handle")
discard("base_ucs_handle")
return dxfattribs
if name.lower() == "model":
return self.target.modelspace()
source_layout = self.source.layouts.get(name) # raises KeyError
target_name = get_target_name()
dxfattribs = clear(source_layout.dxf_layout.dxfattribs())
target_layout = self.target.layouts.new(target_name, dxfattribs=dxfattribs)
return target_layout
def import_paperspace_layout(self, name: str) -> Layout:
"""Import paperspace layout `name` into the target document.
Recreates the source paperspace layout in the target document, renames
the target paperspace if a paperspace with same `name` already exist
and imports all entities from the source paperspace into the target
paperspace.
Args:
name: source paper space name as string
Returns: new created target paperspace :class:`Layout`
Raises:
KeyError: source paperspace does not exist
DXFTypeError: invalid modelspace import
"""
if name.lower() == "model":
raise const.DXFTypeError(
"Can not import modelspace, use method import_modelspace()."
)
source_layout = self.source.layouts.get(name)
target_layout = self.recreate_source_layout(name)
self.import_entities(source_layout, target_layout)
return target_layout
def import_paperspace_layouts(self) -> None:
"""Import all paperspace layouts and their content into the target
document.
Target layouts will be renamed if a layout with the same name already
exist. Layouts will be imported in original tab order.
"""
for name in self.source.layouts.names_in_taborder():
if name.lower() != "model": # do not import modelspace
self.import_paperspace_layout(name)
def import_blocks(self, block_names: Iterable[str], rename=False) -> None:
"""Import all BLOCK definitions from source document.
If a BLOCK already exist the BLOCK will be renamed if argument
`rename` is ``True``, otherwise the existing BLOCK in the target
document will be used instead of the BLOCK from the source document.
Required name resolving for imported BLOCK references (INSERT), will be
done in the :meth:`Importer.finalize` method.
Args:
block_names: names of BLOCK definitions to import
rename: rename BLOCK if a BLOCK with the same name already exist in
target document
Raises:
ValueError: BLOCK in source document not found (defined)
"""
for block_name in block_names:
self.import_block(block_name, rename=rename)
def import_block(self, block_name: str, rename=True) -> str:
"""Import one BLOCK definition from source document.
If the BLOCK already exist the BLOCK will be renamed if argument
`rename` is ``True``, otherwise the existing BLOCK in the target
document will be used instead of the BLOCK in the source document.
Required name resolving for imported block references (INSERT), will be
done in the :meth:`Importer.finalize` method.
To replace an existing BLOCK in the target document, just delete it
before importing data:
:code:`target.blocks.delete_block(block_name, safe=False)`
Args:
block_name: name of BLOCK to import
rename: rename BLOCK if a BLOCK with the same name already exist in
target document
Returns: (renamed) BLOCK name
Raises:
ValueError: BLOCK in source document not found (defined)
"""
def get_new_block_name() -> str:
num = 0
name = block_name
while name in target_blocks:
name = block_name + str(num)
num += 1
return name
try: # already imported block?
return self.imported_blocks[block_name]
except KeyError:
pass
try:
source_block = self.source.blocks[block_name]
except const.DXFKeyError:
raise ValueError(f'Source block "{block_name}" not found.')
target_blocks = self.target.blocks
if (block_name in target_blocks) and (rename is False):
self.imported_blocks[block_name] = block_name
return block_name
new_block_name = get_new_block_name()
block = source_block.block
assert block is not None
target_block = target_blocks.new(
new_block_name,
base_point=block.dxf.base_point,
dxfattribs={
"description": block.dxf.description,
"flags": block.dxf.flags,
"xref_path": block.dxf.xref_path,
},
)
self.import_entities(source_block, target_layout=target_block)
self.imported_blocks[block_name] = new_block_name
return new_block_name
def _create_missing_arrows(self):
"""Create or import required arrow blocks, used by the LEADER or the
DIMSTYLE entity, which are not imported automatically because they are
not used in an anonymous DIMENSION geometry BLOCK.
"""
self.used_arrows.discard(
""
) # standard default arrow '' needs no block definition
for arrow_name in self.used_arrows:
if ARROWS.is_acad_arrow(arrow_name):
self.target.acquire_arrow(arrow_name)
else:
self.import_block(arrow_name, rename=False)
def _resolve_inserts(self) -> None:
"""Resolve BLOCK names of imported BLOCK reference entities (INSERT).
This is required for the case the name of the imported BLOCK collides
with an already existing BLOCK in the target document and the conflict
resolving method is "rename".
"""
while len(self.imported_inserts):
inserts = list(self.imported_inserts)
# clear imported inserts, block import may append additional inserts
self.imported_inserts = []
for insert in inserts:
block_name = self.import_block(insert.dxf.name)
insert.dxf.name = block_name
def _import_required_table_entries(self) -> None:
"""Import required table entries collected while importing entities
into the target document.
"""
# 1. dimstyles import adds additional required linetype and style
# resources and required arrows
if len(self.used_dimstyles):
self.import_table("dimstyles", self.used_dimstyles)
# 2. layers import adds additional required linetype resources
if len(self.used_layers):
self.import_table("layers", self.used_layers)
# 3. complex linetypes adds additional required style resources
if len(self.used_linetypes):
self.import_table("linetypes", self.used_linetypes)
# 4. Text styles do not add additional required resources
if len(self.used_styles):
self.import_table("styles", self.used_styles)
# 5. Shape files are text style entries without a name
if len(self.used_shape_files):
self.import_shape_files(self.used_shape_files)
# 6. Update text style handles of imported complex linetypes:
self.update_complex_linetypes()
def _add_required_complex_linetype_resources(self):
for ltype_name in self.used_linetypes:
try:
ltype = self.source.linetypes.get(ltype_name)
except const.DXFTableEntryError:
continue
self._add_linetype_resources(ltype)
def update_complex_linetypes(self):
std_handle = self.target.styles.get("STANDARD").dxf.handle
for linetype in self.target.linetypes:
if linetype.pattern_tags.is_complex_type():
old_handle = linetype.pattern_tags.get_style_handle()
new_handle = self.handle_mapping.get(old_handle, std_handle)
linetype.pattern_tags.set_style_handle(new_handle)
def finalize(self) -> None:
"""Finalize the import by importing required table entries and BLOCK
definitions, without finalization the target document is maybe invalid
for AutoCAD. Call the :meth:`~Importer.finalize()` method as last step
of the import process.
"""
self._resolve_inserts()
self._add_required_complex_linetype_resources()
self._import_required_table_entries()
self._create_missing_arrows()
def new_clean_entity(entity: DXFEntity, keep_xdata: bool = False) -> DXFEntity:
"""Copy entity and remove all external dependencies.
Args:
entity: DXF entity
keep_xdata: keep xdata flag
"""
new_entity = entity.copy()
new_entity.doc = None
return remove_dependencies(new_entity, keep_xdata=keep_xdata)
def remove_dependencies(entity: DXFEntity, keep_xdata: bool = False) -> DXFEntity:
"""Remove all external dependencies.
Args:
entity: DXF entity
keep_xdata: keep xdata flag
"""
entity.appdata = None
entity.reactors = None
entity.extension_dict = None
if not keep_xdata:
entity.xdata = None
return entity
@@ -0,0 +1,480 @@
# Copyright (c) 2020-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Iterable,
Iterator,
cast,
BinaryIO,
Optional,
Union,
Any,
)
from io import StringIO
from pathlib import Path
from ezdxf.lldxf.const import DXFStructureError
from ezdxf.lldxf.extendedtags import ExtendedTags, DXFTag
from ezdxf.lldxf.tagwriter import TagWriter
from ezdxf.lldxf.tagger import tag_compiler, ascii_tags_loader
from ezdxf.filemanagement import dxf_file_info
from ezdxf.lldxf import fileindex
from ezdxf.entities import DXFGraphic, DXFEntity, Polyline, Insert
from ezdxf.entities import factory
from ezdxf.entities.subentity import entity_linker
from ezdxf.tools.codepage import toencoding
__all__ = ["opendxf", "single_pass_modelspace", "modelspace"]
SUPPORTED_TYPES = {
"ARC",
"LINE",
"CIRCLE",
"ELLIPSE",
"POINT",
"LWPOLYLINE",
"SPLINE",
"3DFACE",
"SOLID",
"TRACE",
"SHAPE",
"POLYLINE",
"VERTEX",
"SEQEND",
"MESH",
"TEXT",
"MTEXT",
"HATCH",
"INSERT",
"ATTRIB",
"ATTDEF",
"RAY",
"XLINE",
"DIMENSION",
"LEADER",
"IMAGE",
"WIPEOUT",
"HELIX",
"MLINE",
"MLEADER",
}
Filename = Union[Path, str]
class IterDXF:
"""Iterator for DXF entities stored in the modelspace.
Args:
name: filename, has to be a seekable file.
errors: specify decoding error handler
- "surrogateescape" to preserve possible binary data (default)
- "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
- "strict" to raise an :class:`UnicodeDecodeError`exception for invalid data
Raises:
DXFStructureError: invalid or incomplete DXF file
UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs
"""
def __init__(self, name: Filename, errors: str = "surrogateescape"):
self.structure, self.sections = self._load_index(str(name))
self.errors = errors
self.file: BinaryIO = open(name, mode="rb")
if "ENTITIES" not in self.sections:
raise DXFStructureError("ENTITIES section not found.")
if self.structure.version > "AC1009" and "OBJECTS" not in self.sections:
raise DXFStructureError("OBJECTS section not found.")
def _load_index(
self, name: str
) -> tuple[fileindex.FileStructure, dict[str, int]]:
structure = fileindex.load(name)
sections: dict[str, int] = dict()
new_index = []
for e in structure.index:
if e.code == 0:
new_index.append(e)
elif e.code == 2:
sections[e.value] = len(new_index) - 1
# remove all other tags like handles (code == 5)
structure.index = new_index
return structure, sections
@property
def encoding(self):
return self.structure.encoding
@property
def dxfversion(self):
return self.structure.version
def export(self, name: Filename) -> IterDXFWriter:
"""Returns a companion object to export parts from the source DXF file
into another DXF file, the new file will have the same HEADER, CLASSES,
TABLES, BLOCKS and OBJECTS sections, which guarantees all necessary
dependencies are present in the new file.
Args:
name: filename, no special requirements
"""
doc = IterDXFWriter(name, self)
# Copy everything from start of source DXF until the first entity
# of the ENTITIES section to the new DXF.
location = self.structure.index[self.sections["ENTITIES"] + 1].location
self.file.seek(0)
data = self.file.read(location)
doc.write_data(data)
return doc
def copy_objects_section(self, f: BinaryIO) -> None:
start_index = self.sections["OBJECTS"]
try:
end_index = self.structure.get(0, "ENDSEC", start_index)
except ValueError:
raise DXFStructureError(f"ENDSEC of OBJECTS section not found.")
start_location = self.structure.index[start_index].location
end_location = self.structure.index[end_index + 1].location
count = end_location - start_location
self.file.seek(start_location)
data = self.file.read(count)
f.write(data)
def modelspace(
self, types: Optional[Iterable[str]] = None
) -> Iterable[DXFGraphic]:
"""Returns an iterator for all supported DXF entities in the
modelspace. These entities are regular :class:`~ezdxf.entities.DXFGraphic`
objects but without a valid document assigned. It is **not**
possible to add these entities to other `ezdxf` documents.
It is only possible to recreate the objects by factory functions base
on attributes of the source entity.
For MESH, POLYMESH and POLYFACE it is possible to use the
:class:`~ezdxf.render.MeshTransformer` class to render (recreate) this
objects as new entities in another document.
Args:
types: DXF types like ``['LINE', '3DFACE']`` which should be
returned, ``None`` returns all supported types.
"""
linked_entity = entity_linker()
queued = None
requested_types = _requested_types(types)
for entity in self.load_entities(
self.sections["ENTITIES"] + 1, requested_types
):
if not linked_entity(entity) and entity.dxf.paperspace == 0:
# queue one entity for collecting linked entities:
# VERTEX, ATTRIB
if queued:
yield queued
queued = entity
if queued:
yield queued
def load_entities(
self, start: int, requested_types: set[str]
) -> Iterable[DXFGraphic]:
def to_str(data: bytes) -> str:
return data.decode(self.encoding, errors=self.errors).replace(
"\r\n", "\n"
)
index = start
entry = self.structure.index[index]
self.file.seek(entry.location)
while entry.value != "ENDSEC":
index += 1
next_entry = self.structure.index[index]
size = next_entry.location - entry.location
data = self.file.read(size)
if entry.value in requested_types:
xtags = ExtendedTags.from_text(to_str(data))
yield factory.load(xtags) # type: ignore
entry = next_entry
def close(self):
"""Safe closing source DXF file."""
self.file.close()
class IterDXFWriter:
def __init__(self, name: Filename, loader: IterDXF):
self.name = str(name)
self.file: BinaryIO = open(name, mode="wb")
self.text = StringIO()
self.entity_writer = TagWriter(self.text, loader.dxfversion)
self.loader = loader
def write_data(self, data: bytes):
self.file.write(data)
def write(self, entity: DXFGraphic):
"""Write a DXF entity from the source DXF file to the export file.
Don't write entities from different documents than the source DXF file,
dependencies and resources will not match, maybe it will work once, but
not in a reliable way for different DXF documents.
"""
# Not necessary to remove this dependencies by copying
# them into the same document frame
# ---------------------------------
# remove all possible dependencies
# entity.xdata = None
# entity.appdata = None
# entity.extension_dict = None
# entity.reactors = None
# reset text stream
self.text.seek(0)
self.text.truncate()
if entity.dxf.handle is None: # DXF R12 without handles
self.entity_writer.write_handles = False
entity.export_dxf(self.entity_writer)
if entity.dxftype() == "POLYLINE":
polyline = cast(Polyline, entity)
for vertex in polyline.vertices:
vertex.export_dxf(self.entity_writer)
polyline.seqend.export_dxf(self.entity_writer) # type: ignore
elif entity.dxftype() == "INSERT":
insert = cast(Insert, entity)
if insert.attribs_follow:
for attrib in insert.attribs:
attrib.export_dxf(self.entity_writer)
insert.seqend.export_dxf(self.entity_writer) # type: ignore
data = self.text.getvalue().encode(self.loader.encoding)
self.file.write(data)
def close(self):
"""Safe closing of exported DXF file. Copying of OBJECTS section
happens only at closing the file, without closing the new DXF file is
invalid.
"""
self.file.write(b" 0\r\nENDSEC\r\n") # for ENTITIES section
if self.loader.dxfversion > "AC1009":
self.loader.copy_objects_section(self.file)
self.file.write(b" 0\r\nEOF\r\n")
self.file.close()
def opendxf(filename: Filename, errors: str = "surrogateescape") -> IterDXF:
"""Open DXF file for iterating, be sure to open valid DXF files, no DXF
structure checks will be applied.
Use this function to split up big DXF files as shown in the example above.
Args:
filename: DXF filename of a seekable DXF file.
errors: specify decoding error handler
- "surrogateescape" to preserve possible binary data (default)
- "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
- "strict" to raise an :class:`UnicodeDecodeError` exception for invalid data
Raises:
DXFStructureError: invalid or incomplete DXF file
UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs
"""
return IterDXF(filename, errors=errors)
def modelspace(
filename: Filename,
types: Optional[Iterable[str]] = None,
errors: str = "surrogateescape",
) -> Iterable[DXFGraphic]:
"""Iterate over all modelspace entities as :class:`DXFGraphic` objects of
a seekable file.
Use this function to iterate "quick" over modelspace entities of a DXF file,
filtering DXF types may speed up things if many entity types will be skipped.
Args:
filename: filename of a seekable DXF file
types: DXF types like ``['LINE', '3DFACE']`` which should be returned,
``None`` returns all supported types.
errors: specify decoding error handler
- "surrogateescape" to preserve possible binary data (default)
- "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
- "strict" to raise an :class:`UnicodeDecodeError` exception for invalid data
Raises:
DXFStructureError: invalid or incomplete DXF file
UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs
"""
info = dxf_file_info(str(filename))
prev_code: int = -1
prev_value: Any = ""
entities = False
requested_types = _requested_types(types)
with open(filename, mode="rt", encoding=info.encoding, errors=errors) as fp:
tagger = ascii_tags_loader(fp)
queued: Optional[DXFEntity] = None
tags: list[DXFTag] = []
linked_entity = entity_linker()
for tag in tag_compiler(tagger):
code = tag.code
value = tag.value
if entities:
if code == 0:
if len(tags) and tags[0].value in requested_types:
entity = factory.load(ExtendedTags(tags))
if (
not linked_entity(entity)
and entity.dxf.paperspace == 0
):
# queue one entity for collecting linked entities:
# VERTEX, ATTRIB
if queued:
yield queued # type: ignore
queued = entity
tags = [tag]
else:
tags.append(tag)
if code == 0 and value == "ENDSEC":
if queued:
yield queued # type: ignore
return
continue # if entities - nothing else matters
elif code == 2 and prev_code == 0 and prev_value == "SECTION":
entities = value == "ENTITIES"
prev_code = code
prev_value = value
def single_pass_modelspace(
stream: BinaryIO,
types: Optional[Iterable[str]] = None,
errors: str = "surrogateescape",
) -> Iterable[DXFGraphic]:
"""Iterate over all modelspace entities as :class:`DXFGraphic` objects in
a single pass.
Use this function to 'quick' iterate over modelspace entities of a **not**
seekable binary DXF stream, filtering DXF types may speed up things if many
entity types will be skipped.
Args:
stream: (not seekable) binary DXF stream
types: DXF types like ``['LINE', '3DFACE']`` which should be returned,
``None`` returns all supported types.
errors: specify decoding error handler
- "surrogateescape" to preserve possible binary data (default)
- "ignore" to use the replacement char U+FFFD "\ufffd" for invalid data
- "strict" to raise an :class:`UnicodeDecodeError` exception for invalid data
Raises:
DXFStructureError: Invalid or incomplete DXF file
UnicodeDecodeError: if `errors` is "strict" and a decoding error occurs
"""
fetch_header_var: Optional[str] = None
encoding = "cp1252"
version = "AC1009"
prev_code: int = -1
prev_value: str = ""
entities = False
requested_types = _requested_types(types)
for code, value in binary_tagger(stream):
if code == 0 and value == b"ENDSEC":
break
elif code == 2 and prev_code == 0 and value != b"HEADER":
# (0, SECTION), (2, name)
# First section is not the HEADER section
entities = value == b"ENTITIES"
break
elif code == 9 and value == b"$DWGCODEPAGE":
fetch_header_var = "ENCODING"
elif code == 9 and value == b"$ACADVER":
fetch_header_var = "VERSION"
elif fetch_header_var == "ENCODING":
encoding = toencoding(value.decode())
fetch_header_var = None
elif fetch_header_var == "VERSION":
version = value.decode()
fetch_header_var = None
prev_code = code
if version >= "AC1021":
encoding = "utf-8"
queued: Optional[DXFGraphic] = None
tags: list[DXFTag] = []
linked_entity = entity_linker()
for tag in tag_compiler(binary_tagger(stream, encoding, errors)):
code = tag.code
value = tag.value
if entities:
if code == 0 and value == "ENDSEC":
if queued:
yield queued
return
if code == 0:
if len(tags) and tags[0].value in requested_types:
entity = cast(DXFGraphic, factory.load(ExtendedTags(tags)))
if not linked_entity(entity) and entity.dxf.paperspace == 0:
# queue one entity for collecting linked entities:
# VERTEX, ATTRIB
if queued:
yield queued
queued = entity
tags = [tag]
else:
tags.append(tag)
continue # if entities - nothing else matters
elif code == 2 and prev_code == 0 and prev_value == "SECTION":
entities = value == "ENTITIES"
prev_code = code
prev_value = value
def binary_tagger(
file: BinaryIO,
encoding: Optional[str] = None,
errors: str = "surrogateescape",
) -> Iterator[DXFTag]:
while True:
try:
try:
code = int(file.readline())
except ValueError:
raise DXFStructureError(f"Invalid group code")
value = file.readline().rstrip(b"\r\n")
yield DXFTag(
code,
value.decode(encoding, errors=errors) if encoding else value,
)
except IOError:
return
def _requested_types(types: Optional[Iterable[str]]) -> set[str]:
if types:
requested = SUPPORTED_TYPES.intersection(set(types))
if "POLYLINE" in requested:
requested.add("SEQEND")
requested.add("VERTEX")
if "INSERT" in requested:
requested.add("SEQEND")
requested.add("ATTRIB")
else:
requested = SUPPORTED_TYPES
return requested
@@ -0,0 +1,266 @@
# Purpose: menger sponge addon for ezdxf
# Copyright (c) 2016-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator, Sequence, Optional
from ezdxf.math import Vec3, UVec, Matrix44, UCS
from ezdxf.render.mesh import MeshVertexMerger, MeshTransformer, MeshBuilder
if TYPE_CHECKING:
from ezdxf.eztypes import GenericLayoutType
# fmt: off
all_cubes_size_3_template = [
(0, 0, 0), (1, 0, 0), (2, 0, 0), (0, 1, 0), (1, 1, 0), (2, 1, 0), (0, 2, 0), (1, 2, 0), (2, 2, 0),
(0, 0, 1), (1, 0, 1), (2, 0, 1), (0, 1, 1), (1, 1, 1), (2, 1, 1), (0, 2, 1), (1, 2, 1), (2, 2, 1),
(0, 0, 2), (1, 0, 2), (2, 0, 2), (0, 1, 2), (1, 1, 2), (2, 1, 2), (0, 2, 2), (1, 2, 2), (2, 2, 2),
]
original_menger_cubes = [
(0, 0, 0), (1, 0, 0), (2, 0, 0), (0, 1, 0), (2, 1, 0), (0, 2, 0), (1, 2, 0), (2, 2, 0),
(0, 0, 1), (2, 0, 1), (0, 2, 1), (2, 2, 1),
(0, 0, 2), (1, 0, 2), (2, 0, 2), (0, 1, 2), (2, 1, 2), (0, 2, 2), (1, 2, 2), (2, 2, 2),
]
menger_v1 = [
(0, 0, 0), (2, 0, 0), (1, 1, 0), (0, 2, 0), (2, 2, 0),
(1, 0, 1), (0, 1, 1), (2, 1, 1), (1, 2, 1),
(0, 0, 2), (2, 0, 2), (1, 1, 2), (0, 2, 2), (2, 2, 2),
]
menger_v2 = [
(1, 0, 0), (0, 1, 0), (2, 1, 0), (1, 2, 0),
(0, 0, 1), (2, 0, 1), (1, 1, 1), (0, 2, 1), (2, 2, 1),
(1, 0, 2), (0, 1, 2), (2, 1, 2), (1, 2, 2),
]
jerusalem_cube = [
(0, 0, 0), (1, 0, 0), (2, 0, 0), (3, 0, 0), (4, 0, 0), (0, 1, 0), (1, 1, 0), (3, 1, 0), (4, 1, 0), (0, 2, 0),
(4, 2, 0), (0, 3, 0), (1, 3, 0), (3, 3, 0), (4, 3, 0), (0, 4, 0), (1, 4, 0), (2, 4, 0), (3, 4, 0), (4, 4, 0),
(0, 0, 1), (1, 0, 1), (3, 0, 1), (4, 0, 1), (0, 1, 1), (1, 1, 1), (3, 1, 1), (4, 1, 1), (0, 3, 1), (1, 3, 1),
(3, 3, 1), (4, 3, 1), (0, 4, 1), (1, 4, 1), (3, 4, 1), (4, 4, 1), (0, 0, 2), (4, 0, 2), (0, 4, 2), (4, 4, 2),
(0, 0, 3), (1, 0, 3), (3, 0, 3), (4, 0, 3), (0, 1, 3), (1, 1, 3), (3, 1, 3), (4, 1, 3), (0, 3, 3), (1, 3, 3),
(3, 3, 3), (4, 3, 3), (0, 4, 3), (1, 4, 3), (3, 4, 3), (4, 4, 3), (0, 0, 4), (1, 0, 4), (2, 0, 4), (3, 0, 4),
(4, 0, 4), (0, 1, 4), (1, 1, 4), (3, 1, 4), (4, 1, 4), (0, 2, 4), (4, 2, 4), (0, 3, 4), (1, 3, 4), (3, 3, 4),
(4, 3, 4), (0, 4, 4), (1, 4, 4), (2, 4, 4), (3, 4, 4), (4, 4, 4),
]
building_schemas = [
original_menger_cubes,
menger_v1,
menger_v2,
jerusalem_cube,
]
# subdivide level in order of building_schemas
cube_sizes = [3., 3., 3., 5.]
# 8 corner vertices
_cube_vertices = [
(0, 0, 0),
(1, 0, 0),
(1, 1, 0),
(0, 1, 0),
(0, 0, 1),
(1, 0, 1),
(1, 1, 1),
(0, 1, 1),
]
# 6 cube faces
cube_faces = [
[0, 3, 2, 1],
[4, 5, 6, 7],
[0, 1, 5, 4],
[1, 2, 6, 5],
[3, 7, 6, 2],
[0, 4, 7, 3],
]
# fmt: on
class MengerSponge:
"""
Args:
location: location of lower left corner as (x, y, z) tuple
length: side length
level: subdivide level
kind: type of menger sponge
=== ===========================
0 Original Menger Sponge
1 Variant XOX
2 Variant OXO
3 Jerusalem Cube
=== ===========================
"""
def __init__(
self,
location: UVec = (0.0, 0.0, 0.0),
length: float = 1.0,
level: int = 1,
kind: int = 0,
):
self.cube_definitions = _menger_sponge(
location=location, length=length, level=level, kind=kind
)
def vertices(self) -> Iterator[list[Vec3]]:
"""Yields the cube vertices as list of (x, y, z) tuples."""
for location, length in self.cube_definitions:
x, y, z = location
yield [
Vec3(x + xf * length, y + yf * length, z + zf * length)
for xf, yf, zf in _cube_vertices
]
__iter__ = vertices
@staticmethod
def faces() -> list[list[int]]:
"""Returns list of cube faces. All cube vertices have the same order, so
one faces list fits them all.
"""
return cube_faces
def render(
self,
layout: GenericLayoutType,
merge: bool = False,
dxfattribs=None,
matrix: Optional[Matrix44] = None,
ucs: Optional[UCS] = None,
) -> None:
"""Renders the menger sponge into layout, set `merge` to ``True`` for
rendering the whole menger sponge into one MESH entity, set `merge` to
``False`` for rendering the individual cubes of the menger sponge as
MESH entities.
Args:
layout: DXF target layout
merge: ``True`` for one MESH entity, ``False`` for individual MESH
entities per cube
dxfattribs: DXF attributes for the MESH entities
matrix: apply transformation matrix at rendering
ucs: apply UCS transformation at rendering
"""
if merge:
mesh = self.mesh()
mesh.render_mesh(
layout, dxfattribs=dxfattribs, matrix=matrix, ucs=ucs
)
else:
for cube in self.cubes():
cube.render_mesh(layout, dxfattribs, matrix=matrix, ucs=ucs)
def cubes(self) -> Iterator[MeshTransformer]:
"""Yields all cubes of the menger sponge as individual
:class:`MeshTransformer` objects.
"""
faces = self.faces()
for vertices in self:
mesh = MeshVertexMerger()
mesh.add_mesh(vertices=vertices, faces=faces) # type: ignore
yield MeshTransformer.from_builder(mesh)
def mesh(self) -> MeshTransformer:
"""Returns geometry as one :class:`MeshTransformer` object."""
faces = self.faces()
mesh = MeshVertexMerger()
for vertices in self:
mesh.add_mesh(vertices=vertices, faces=faces) # type: ignore
return remove_duplicate_inner_faces(mesh)
def remove_duplicate_inner_faces(mesh: MeshBuilder) -> MeshTransformer:
new_mesh = MeshTransformer()
new_mesh.vertices = mesh.vertices
new_mesh.faces = list(manifold_faces(mesh.faces))
return new_mesh
def manifold_faces(faces: list[Sequence[int]]) -> Iterator[Sequence[int]]:
ledger: dict[tuple[int, ...], list[Sequence[int]]] = {}
for face in faces:
key = tuple(sorted(face))
try:
ledger[key].append(face)
except KeyError:
ledger[key] = [face]
for faces in ledger.values():
if len(faces) == 1:
yield faces[0]
def _subdivide(
location: UVec = (0.0, 0.0, 0.0), length: float = 1.0, kind: int = 0
) -> list[tuple[Vec3, float]]:
"""Divides a cube in sub-cubes and keeps only cubes determined by the
building schema.
All sides are parallel to x-, y- and z-axis, location is a (x, y, z) tuple
and represents the coordinates of the lower left corner (nearest to the axis
origin) of the cube, length is the side-length of the cube
Args:
location: (x, y, z) tuple, coordinates of the lower left corner of the cube
length: side length of the cube
kind: int for 0: original menger sponge; 1: Variant XOX; 2: Variant OXO;
3: Jerusalem Cube;
Returns: list of sub-cubes (location, length)
"""
init_x, init_y, init_z = location
step_size = float(length) / cube_sizes[kind]
remaining_cubes = building_schemas[kind]
def sub_location(indices) -> Vec3:
x, y, z = indices
return Vec3(
init_x + x * step_size,
init_y + y * step_size,
init_z + z * step_size,
)
return [(sub_location(indices), step_size) for indices in remaining_cubes]
def _menger_sponge(
location: UVec = (0.0, 0.0, 0.0),
length: float = 1.0,
level: int = 1,
kind: int = 0,
) -> list[tuple[Vec3, float]]:
"""Builds a menger sponge for given level.
Args:
location: (x, y, z) tuple, coordinates of the lower left corner of the cube
length: side length of the cube
level: level of menger sponge, has to be 1 or bigger
kind: int for 0: original menger sponge; 1: Variant XOX; 2: Variant OXO;
3: Jerusalem Cube;
Returns: list of sub-cubes (location, length)
"""
kind = int(kind)
if kind not in (0, 1, 2, 3):
raise ValueError("kind has to be 0, 1, 2 or 3.")
level = int(level)
if level < 1:
raise ValueError("level has to be 1 or bigger.")
cubes = _subdivide(location, length, kind=kind)
for _ in range(level - 1):
next_level_cubes = []
for location, length in cubes:
next_level_cubes.extend(_subdivide(location, length, kind=kind))
cubes = next_level_cubes
return cubes
@@ -0,0 +1,675 @@
# Copyright (c) 2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Union, Sequence
import os
import struct
import uuid
import datetime
import enum
import zipfile
from ezdxf.math import Vec3, normal_vector_3p, BoundingBox
from ezdxf.render import MeshTransformer, MeshVertexMerger, MeshBuilder
from ezdxf import __version__
class UnsupportedFileFormat(Exception):
pass
class ParsingError(Exception):
pass
def stl_readfile(filename: Union[str, os.PathLike]) -> MeshTransformer:
"""Read ascii or binary `STL`_ file content as :class:`ezdxf.render.MeshTransformer`
instance.
Raises:
ParsingError: vertex parsing error or invalid/corrupt data
"""
with open(filename, "rb") as fp:
buffer = fp.read()
if buffer.startswith(b"solid"):
s = buffer.decode("ascii", errors="ignore")
return stl_loads(s)
else:
return stl_loadb(buffer)
def stl_loads(content: str) -> MeshTransformer:
"""Load a mesh from an ascii `STL`_ content string as :class:`ezdxf.render.MeshTransformer`
instance.
Raises:
ParsingError: vertex parsing error
"""
# http://www.fabbers.com/tech/STL_Format#Sct_ASCII
# This implementation is not very picky and grabs only lines which start
# with "vertex" or "endloop" and ignores the rest.
def parse_vertex(line: str) -> Vec3:
data = line.split()
return Vec3(float(data[1]), float(data[2]), float(data[3]))
mesh = MeshVertexMerger()
face: list[Vec3] = []
for num, line in enumerate(content.split("\n"), start=1):
line = line.strip(" \r")
if line.startswith("vertex"):
try:
face.append(parse_vertex(line))
except (IndexError, ValueError):
raise ParsingError(f"STL parsing error in line {num}: {line}")
elif line.startswith("endloop"):
if len(face) == 3:
mesh.add_face(face)
face.clear()
return MeshTransformer.from_builder(mesh)
def stl_loadb(buffer: bytes) -> MeshTransformer:
"""Load a mesh from a binary `STL`_ data :class:`ezdxf.render.MeshTransformer`
instance.
Raises:
ParsingError: invalid/corrupt data or not a binary STL file
"""
# http://www.fabbers.com/tech/STL_Format#Sct_ASCII
index = 80
n_faces = struct.unpack_from("<I", buffer, index)[0]
index += 4
mesh = MeshVertexMerger()
for _ in range(n_faces):
try:
face = struct.unpack_from("<12fH", buffer, index)
except struct.error:
raise ParsingError("binary STL parsing error")
index += 50
v1 = Vec3(face[3:6])
v2 = Vec3(face[6:9])
v3 = Vec3(face[9:12])
mesh.add_face((v1, v2, v3))
return MeshTransformer.from_builder(mesh)
def off_readfile(filename: Union[str, os.PathLike]) -> MeshTransformer:
"""Read `OFF`_ file content as :class:`ezdxf.render.MeshTransformer`
instance.
Raises:
ParsingError: vertex or face parsing error
"""
with open(filename, "rt", encoding="ascii", errors="ignore") as fp:
content = fp.read()
return off_loads(content)
def off_loads(content: str) -> MeshTransformer:
"""Load a mesh from a `OFF`_ content string as :class:`ezdxf.render.MeshTransformer`
instance.
Raises:
ParsingError: vertex or face parsing error
"""
# https://en.wikipedia.org/wiki/OFF_(file_format)
mesh = MeshVertexMerger()
lines: list[str] = []
for line in content.split("\n"):
line = line.strip(" \n\r")
# "OFF" in a single line
if line.startswith("#") or line == "OFF" or line == "":
continue
lines.append(line)
if len(lines) == 0:
raise ParsingError(f"OFF format parsing error: no data")
if lines[0].startswith("OFF"):
# OFF v f e
lines[0] = lines[0][4:]
n = lines[0].split()
try:
n_vertices, n_faces = int(n[0]), int(n[1])
except ValueError:
raise ParsingError(f"OFF format parsing error: {lines[0]}")
if len(lines) < n_vertices + n_faces:
raise ParsingError(f"OFF format parsing error: invalid data count")
for vertex in lines[1 : n_vertices + 1]:
v = vertex.split()
try:
vtx = Vec3(float(v[0]), float(v[1]), float(v[2]))
except (ValueError, IndexError):
raise ParsingError(f"OFF format vertex parsing error: {vertex}")
mesh.vertices.append(vtx)
index = n_vertices + 1
face_indices = []
for face in lines[index : index + n_faces]:
f = face.split()
try:
vertex_count = int(f[0])
except ValueError:
raise ParsingError(f"OFF format face parsing error: {face}")
for index in range(vertex_count):
try:
face_indices.append(int(f[1 + index]))
except (ValueError, IndexError):
raise ParsingError(
f"OFF format face index parsing error: {face}"
)
mesh.faces.append(tuple(face_indices))
face_indices.clear()
return MeshTransformer.from_builder(mesh)
def obj_readfile(filename: Union[str, os.PathLike]) -> list[MeshTransformer]:
"""Read `OBJ`_ file content as list of :class:`ezdxf.render.MeshTransformer`
instances.
Raises:
ParsingError: vertex or face parsing error
"""
with open(filename, "rt", encoding="ascii", errors="ignore") as fp:
content = fp.read()
return obj_loads(content)
def obj_loads(content: str) -> list[MeshTransformer]:
"""Load one or more meshes from an `OBJ`_ content string as list of
:class:`ezdxf.render.MeshTransformer` instances.
Raises:
ParsingError: vertex parsing error
"""
# https://en.wikipedia.org/wiki/Wavefront_.obj_file
# This implementation is not very picky and grabs only lines which start
# with "v", "g" or "f" and ignores the rest.
def parse_vertex(l: str) -> Vec3:
v = l.split()
return Vec3(float(v[0]), float(v[1]), float(v[2]))
def parse_face(l: str) -> Sequence[int]:
return tuple(int(s.split("/")[0]) for s in l.split())
vertices: list[Vec3] = [Vec3()] # 1-indexed
meshes: list[MeshTransformer] = []
mesh = MeshVertexMerger()
for num, line in enumerate(content.split("\n"), start=1):
line = line.strip(" \r")
if line.startswith("v"):
try:
vtx = parse_vertex(line[2:])
except (IndexError, ValueError):
raise ParsingError(
f"OBJ vertex parsing error in line {num}: {line}"
)
vertices.append(vtx)
elif line.startswith("f"):
try:
mesh.add_face(vertices[i] for i in parse_face(line[2:]))
except ValueError:
raise ParsingError(
f"OBJ face parsing error in line {num}: {line}"
)
except IndexError:
raise ParsingError(
f"OBJ face index error (n={len(vertices)}) in line {num}: {line}"
)
elif line.startswith("g") and len(mesh.vertices) > 0:
meshes.append(MeshTransformer.from_builder(mesh))
mesh = MeshVertexMerger()
if len(mesh.vertices) > 0:
meshes.append(MeshTransformer.from_builder(mesh))
return meshes
def stl_dumps(mesh: MeshBuilder) -> str:
"""Returns the `STL`_ data as string for the given `mesh`.
This function triangulates the meshes automatically because the `STL`_
format supports only triangles as faces.
This function does not check if the mesh obey the
`STL`_ format `rules <http://www.fabbers.com/tech/STL_Format>`_:
- The direction of the face normal is outward.
- The face vertices are listed in counter-clockwise order when looking
at the object from the outside (right-hand rule).
- Each triangle must share two vertices with each of its adjacent triangles.
- The object represented must be located in the all-positive octant
(non-negative and nonzero).
"""
lines: list[str] = [f"solid STL generated by ezdxf {__version__}"]
for face in mesh.tessellation(max_vertex_count=3):
if len(face) < 3:
continue
try:
n = normal_vector_3p(face[0], face[1], face[2])
except ZeroDivisionError:
continue
n = n.round(8)
lines.append(f" facet normal {n.x} {n.y} {n.z}")
lines.append(" outer loop")
for v in face:
lines.append(f" vertex {v.x} {v.y} {v.z}")
lines.append(" endloop")
lines.append(" endfacet")
lines.append("endsolid\n")
return "\n".join(lines)
STL_SIGNATURE = (b"STL generated ezdxf" + b" " * 80)[:80]
def stl_dumpb(mesh: MeshBuilder) -> bytes:
"""Returns the `STL`_ binary data as bytes for the given `mesh`.
For more information see function: :func:`stl_dumps`
"""
data: list[bytes] = [STL_SIGNATURE, b"0000"]
count = 0
for face in mesh.tessellation(max_vertex_count=3):
try:
n = normal_vector_3p(face[0], face[1], face[2])
except ZeroDivisionError:
continue
count += 1
values = list(n.xyz)
for v in face:
values.extend(v.xyz)
values.append(0)
data.append(struct.pack("<12fH", *values))
data[1] = struct.pack("<I", count)
return b"".join(data)
def off_dumps(mesh: MeshBuilder) -> str:
"""Returns the `OFF`_ data as string for the given `mesh`.
The `OFF`_ format supports ngons as faces.
"""
lines: list[str] = ["OFF", f"{len(mesh.vertices)} {len(mesh.faces)} 0"]
for v in mesh.vertices:
v = v.round(6)
lines.append(f"{v.x} {v.y} {v.z}")
for face in mesh.open_faces():
lines.append(f"{len(face)} {' '.join(str(i) for i in face)}")
lines[-1] += "\n"
return "\n".join(lines)
def obj_dumps(mesh: MeshBuilder) -> str:
"""Returns the `OBJ`_ data as string for the given `mesh`.
The `OBJ`_ format supports ngons as faces.
"""
lines: list[str] = [f"# OBJ generated by ezdxf {__version__}"]
for v in mesh.vertices:
v = v.round(6)
lines.append(f"v {v.x} {v.y} {v.z}")
for face in mesh.open_faces():
# OBJ is 1-index
lines.append("f " + " ".join(str(i + 1) for i in face))
lines[-1] += "\n"
return "\n".join(lines)
def scad_dumps(mesh: MeshBuilder) -> str:
"""Returns the `OpenSCAD`_ `polyhedron`_ definition as string for the given
`mesh`. `OpenSCAD`_ supports ngons as faces.
.. Important::
`OpenSCAD`_ requires the face normals pointing inwards, the method
:meth:`~ezdxf.render.MeshBuilder.flip_normals` of the
:class:`~ezdxf.render.MeshBuilder` class can flip the normals
inplace.
"""
# polyhedron( points = [ [X0, Y0, Z0], [X1, Y1, Z1], ... ], faces = [ [P0, P1, P2, P3, ...], ... ], convexity = N); // 2014.03 & later
lines: list[str] = ["polyhedron(points = ["]
for v in mesh.vertices:
v = v.round(6)
lines.append(f" [{v.x}, {v.y}, {v.z}],")
# OpenSCAD accept the last ","
lines.append("], faces = [")
for face in mesh.open_faces():
lines.append(" [" + ", ".join(str(i) for i in face) + "],")
# OpenSCAD accept the last ","
lines.append("], convexity = 10);\n")
return "\n".join(lines)
def ply_dumpb(mesh: MeshBuilder) -> bytes:
"""Returns the `PLY`_ binary data as bytes for the given `mesh`.
The `PLY`_ format supports ngons as faces.
"""
if any(len(f) > 255 for f in mesh.faces):
face_hdr_fmt = b"property list int int vertex_index"
face_fmt = "<i{}i"
else:
face_hdr_fmt = b"property list uchar int vertex_index"
face_fmt = "<B{}i"
header: bytes = b"\n".join(
[
b"ply",
b"format binary_little_endian 1.0",
b"comment generated by ezdxf " + __version__.encode(),
b"element vertex " + str(len(mesh.vertices)).encode(),
b"property float x",
b"property float y",
b"property float z",
b"element face " + str(len(mesh.faces)).encode(),
face_hdr_fmt,
b"end_header\n",
]
)
data: list[bytes] = [header]
for vertex in mesh.vertices:
data.append(struct.pack("<3f", vertex.x, vertex.y, vertex.z))
for face in mesh.open_faces():
count = len(face)
fmt = face_fmt.format(count)
data.append(struct.pack(fmt, count, *face))
return b"".join(data)
def ifc_guid() -> str:
return _guid_compress(uuid.uuid4().hex)
def _guid_compress(g: str) -> str:
# https://github.com/IfcOpenShell/IfcOpenShell/blob/master/src/ifcopenshell-python/ifcopenshell/guid.py#L56
chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$"
bs = [int(g[i : i + 2], 16) for i in range(0, len(g), 2)]
def b64(v: int, size: int = 4):
return "".join(
[chars[(v // (64**i)) % 64] for i in range(size)][::-1]
)
return "".join(
[b64(bs[0], 2)]
+ [
b64((bs[i] << 16) + (bs[i + 1] << 8) + bs[i + 2])
for i in range(1, 16, 3)
]
)
class IfcEntityType(enum.Enum):
POLYGON_FACE_SET = "POLY_FACE_SET"
CLOSED_SHELL = "CLOSED_SHELL"
OPEN_SHELL = "OPEN_SHELL"
class Records:
def __init__(self) -> None:
# Record numbers are 1-based, record index in list is 0-based!
# records[0] is record #1
self.records: list[str] = []
@property
def last_num(self) -> int: # list index
return len(self.records)
@property
def prev_num(self) -> int: # list index
return self.last_num - 1
@property
def next_num(self) -> int: # list index
return self.last_num + 1
def add(self, record: str, num: int = 0) -> str:
assert record.endswith(");"), "invalid structure"
self.records.append(record)
if num != 0 and num != len(self.records):
raise ValueError("unexpected record number")
num = len(self.records)
return f"#{num}"
def add_dummy(self):
self.records.append("")
def get(self, num: int) -> str:
return self.records[num - 1]
def update_all(self, tag: str, record_num: str):
for index, record in enumerate(self.records):
if tag in record:
self.update_record(index, tag, record_num)
def update_record(self, index, tag: str, record_num: str):
self.records[index] = self.records[index].replace(tag, record_num)
def dumps(self) -> str:
return "\n".join(
f"#{num+1}= {data}" for num, data in enumerate(self.records) if data
)
def ifc4_dumps(
mesh: MeshBuilder,
entity_type=IfcEntityType.POLYGON_FACE_SET,
*,
layer: str = "MeshExport",
color: tuple[float, float, float] = (1.0, 1.0, 1.0),
) -> str:
"""Returns the `IFC4`_ string for the given `mesh`. The caller is
responsible for checking if the mesh is a closed or open surface
(e.g. :code:`mesh.diagnose().euler_characteristic == 2`) and using the
appropriate entity type.
Args:
mesh: :class:`~ezdxf.render.MeshBuilder`
entity_type: :class:`IfcEntityType`
layer: layer name as string
color: entity color as RGB tuple, values in the range [0,1]
.. warning::
`IFC4`_ is a very complex data format and this is a minimal effort
exporter, so the exported data may not be importable by all CAD
applications.
The exported `IFC4`_ data can be imported by the following applications:
- BricsCAD
- FreeCAD (IfcOpenShell)
- Allplan
- Tekla BIMsight
"""
def make_header():
date = datetime.datetime.now().isoformat()[:-7]
return f"""ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('ViewDefinition [CoordinationView_V2.0]'),'2;1');
FILE_NAME('undefined.ifc','{date}',('Undefined'),('Undefined'),'ezdxf {__version__}','ezdxf {__version__}','Undefined');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
"""
def make_data_records() -> Records:
records = Records()
# fmt: off
if entity_type == IfcEntityType.POLYGON_FACE_SET:
kind = "SurfaceModel"
elif entity_type == IfcEntityType.OPEN_SHELL:
kind = "SurfaceModel"
elif entity_type == IfcEntityType.CLOSED_SHELL:
kind = "Brep"
else:
raise ValueError(f"invalid entity type: {entity_type}")
# for simplicity the first part has absolute record numbering:
records.add(f"IFCPROJECT('{ifc_guid()}',#2,'MeshExport',$,$,$,$,(#7),#13);", 1)
records.add("IFCOWNERHISTORY(#3,#6,$,$,$,$,$,0);", 2)
records.add("IFCPERSONANDORGANIZATION(#4,#5,$);", 3)
records.add("IFCPERSON($,$,'Undefined',$,$,$,$,$);", 4)
records.add("IFCORGANIZATION($,'Undefined',$,$,$);", 5)
records.add(f"IFCAPPLICATION(#5,'{__version__}','ezdxf','ezdxf');", 6)
records.add("IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.000000000000000E-05,#8,#12);", 7)
records.add("IFCAXIS2PLACEMENT3D(#9,#10,#11);", 8)
records.add("IFCCARTESIANPOINT((0.,0.,0.));", 9)
records.add("IFCDIRECTION((0.,0.,1.));", 10)
records.add("IFCDIRECTION((1.,0.,0.));", 11)
records.add("IFCDIRECTION((1.,0.));", 12)
records.add("IFCUNITASSIGNMENT((#14,#15,#16,#17,#18,#19));", 13)
records.add("IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);", 14)
records.add("IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);", 15)
records.add("IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);", 16)
records.add("IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);", 17)
records.add("IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.);", 18)
records.add("IFCSIUNIT(*,.MASSUNIT.,$,.GRAM.);", 19)
records.add("IFCLOCALPLACEMENT($,#8);", 20)
building = records.add(f"IFCBUILDING('{ifc_guid()}',#2,'MeshExport',$,$, #20,$,$,.ELEMENT.,$,$,$);", 21)
records.add_dummy() # I don't wanna redo the absolute record numbering
proxy = records.add(f"IFCBUILDINGELEMENTPROXY('{ifc_guid()}',#2,$,$,$,#24,#29,$,$);", 23)
records.add("IFCLOCALPLACEMENT(#25,#26);", 24)
records.add("IFCLOCALPLACEMENT(#20,#8);", 25)
records.add("IFCAXIS2PLACEMENT3D(#27,#10,#28);", 26)
records.add(f"IFCCARTESIANPOINT(({emin.x},{emin.y},{emin.z}));", 27)
records.add("IFCDIRECTION((1.,0.,0.));", 28)
records.add("IFCPRODUCTDEFINITIONSHAPE($,$,(#30));", 29)
shape = records.add(f"IFCSHAPEREPRESENTATION(#31,'Body','{kind}',($ENTITY$));", 30)
records.add("IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#7,$,.MODEL_VIEW.,$);", 31)
# from here on only relative record numbering:
entity = "#0"
if entity_type == IfcEntityType.POLYGON_FACE_SET:
entity = make_polygon_face_set(records)
elif entity_type == IfcEntityType.CLOSED_SHELL:
entity = make_shell(records)
elif entity_type == IfcEntityType.OPEN_SHELL:
entity = make_shell(records)
color_str = f"IFCCOLOURRGB($,{color[0]:.3f},{color[1]:.3f},{color[2]:.3f});"
records.add(color_str)
records.add(f"IFCSURFACESTYLESHADING(#{records.prev_num+1},0.);")
records.add(f"IFCSURFACESTYLE($,.POSITIVE.,(#{records.prev_num+1}));")
records.add(f"IFCPRESENTATIONSTYLEASSIGNMENT((#{records.prev_num+1}));")
records.add(f"IFCSTYLEDITEM($ENTITY$,(#{records.prev_num+1}),$);")
records.add(f"IFCPRESENTATIONLAYERWITHSTYLE('{layer}',$,({shape}),$,.T.,.F.,.F.,(#{records.next_num+1}));")
records.add(f"IFCSURFACESTYLE($,.POSITIVE.,(#{records.next_num+1}));")
records.add(f"IFCSURFACESTYLESHADING(#{records.next_num+1},0.);")
records.add(color_str)
records.add(f"IFCRELCONTAINEDINSPATIALSTRUCTURE('{ifc_guid()}',#2,$,$,({proxy}),{building});")
records.add(f"IFCRELAGGREGATES('{ifc_guid()}',#2,$,$,#1,({building}));")
# fmt: on
records.update_all("$ENTITY$", entity)
return records
def make_polygon_face_set(records: Records) -> str:
entity = records.add(
f"IFCPOLYGONALFACESET(#{records.next_num+1},$,($FACES$), $);"
)
entity_num = records.last_num
vertices = ",".join([str(v.xyz) for v in mesh.vertices])
records.add(f"IFCCARTESIANPOINTLIST3D(({vertices}));")
face_records: list[str] = []
for face in mesh.open_faces():
indices = ",".join(str(i + 1) for i in face) # 1-based indexing
face_records.append(
records.add(f"IFCINDEXEDPOLYGONALFACE(({indices}));")
)
# list index required
records.update_record(entity_num - 1, "$FACES$", ",".join(face_records))
return entity
def make_shell(records: Records) -> str:
if entity_type == IfcEntityType.CLOSED_SHELL:
entity = records.add(f"IFCFACETEDBREP(#{records.next_num+1});")
records.add(f"IFCCLOSEDSHELL(($FACES$));")
elif entity_type == IfcEntityType.OPEN_SHELL:
entity = records.add(
f"IFCSHELLBASEDSURFACEMODEL((#{records.next_num + 1}));"
)
records.add(f"IFCOPENSHELL(($FACES$));")
else:
raise ValueError(f"invalid entity type: {entity_type}")
shell_num = records.last_num
# add vertices
first_vertex = records.next_num
for v in mesh.vertices:
records.add(f"IFCCARTESIANPOINT({str(v.xyz)});")
# add faces
face_records: list[str] = []
for face in mesh.open_faces():
vertices = ",".join("#" + str(first_vertex + i) for i in face)
records.add(f"IFCPOLYLOOP(({vertices}));")
records.add(f"IFCFACEOUTERBOUND(#{records.prev_num+1},.T.);")
face_records.append(
records.add(f"IFCFACE((#{records.prev_num+1}));")
)
# list index required
records.update_record(shell_num - 1, "$FACES$", ",".join(face_records))
return entity
if len(mesh.vertices) == 0:
return ""
bbox = BoundingBox(mesh.vertices)
emin = Vec3()
assert bbox.extmin is not None
if bbox.extmin.x < 0 or bbox.extmin.y < 0 or bbox.extmin.z < 0:
# Allplan (IFC?) requires all mesh vertices in the all-positive octant
# (non-negative). Record #27 does the final placement at the correct
# location.
emin = bbox.extmin
mesh = MeshTransformer.from_builder(mesh)
mesh.translate(-emin.x, -emin.y, -emin.z)
header = make_header()
data = make_data_records()
return header + data.dumps() + "\nENDSEC;\nEND-ISO-10303-21;\n"
def export_ifcZIP(
filename: Union[str, os.PathLike],
mesh: MeshBuilder,
entity_type=IfcEntityType.POLYGON_FACE_SET,
*,
layer: str = "MeshExport",
color: tuple[float, float, float] = (1.0, 1.0, 1.0),
):
"""Export the given `mesh` as zip-compressed `IFC4`_ file. The filename
suffix should be ``.ifcZIP``. For more information see function
:func:`ifc4_dumps`.
Args:
filename: zip filename, the data file has the same name with suffix ``.ifc``
mesh: :class:`~ezdxf.render.MeshBuilder`
entity_type: :class:`IfcEntityType`
layer: layer name as string
color: entity color as RGB tuple, values in the range [0,1]
Raises:
IOError: IO error when opening the zip-file for writing
"""
name = os.path.basename(filename) + ".ifc"
zf = zipfile.ZipFile(filename, mode="w", compression=zipfile.ZIP_DEFLATED)
try:
zf.writestr(
name, ifc4_dumps(mesh, entity_type, layer=layer, color=color)
)
finally:
zf.close()
@@ -0,0 +1,19 @@
# Copyright (c) 2011, Manfred Moitzi
# License: MIT License
class SubscriptAttributes:
def __getitem__(self, item):
if hasattr(self, item):
return getattr(self, item)
else:
raise KeyError(item)
def __setitem__(self, key, value):
if hasattr(self, key):
setattr(self, key, value)
else:
raise KeyError(key)
def __contains__(self, item):
return hasattr(self, item)
@@ -0,0 +1,174 @@
# Copyright (c) 2010-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import math
import enum
from ezdxf.enums import (
MTextEntityAlignment,
MAP_MTEXT_ALIGN_TO_FLAGS,
)
from ezdxf.lldxf import const
from ezdxf.math import UVec, Vec3
from .mixins import SubscriptAttributes
if TYPE_CHECKING:
from ezdxf.eztypes import GenericLayoutType
TOP_ALIGN = {
MTextEntityAlignment.TOP_LEFT,
MTextEntityAlignment.TOP_RIGHT,
MTextEntityAlignment.TOP_CENTER,
}
MIDDLE_ALIGN = {
MTextEntityAlignment.MIDDLE_LEFT,
MTextEntityAlignment.MIDDLE_CENTER,
MTextEntityAlignment.MIDDLE_RIGHT,
}
class Mirror(enum.IntEnum):
NONE = 0
MIRROR_X = 2
MIRROR_Y = 4
class MTextSurrogate(SubscriptAttributes):
"""MTEXT surrogate for DXF R12 build up by TEXT Entities. This add-on was
added to simplify the transition from :mod:`dxfwrite` to :mod:`ezdxf`.
The rich-text formatting capabilities for the regular MTEXT entity are not
supported, if these features are required use the regular MTEXT entity and
the :class:`~ezdxf.addons.MTextExplode` add-on to explode the MTEXT entity
into DXF primitives.
.. important::
The align-point is always the insert-point, there is no need for
a second align-point because the horizontal alignments FIT, ALIGN,
BASELINE_MIDDLE are not supported.
Args:
text: content as string
insert: insert location in drawing units
line_spacing: line spacing in percent of height, 1.5 = 150% = 1+1/2 lines
align: text alignment as :class:`~ezdxf.enums.MTextEntityAlignment` enum
char_height: text height in drawing units
style: :class:`~ezdxf.entities.Textstyle` name as string
oblique: oblique angle in degrees, where 0 is vertical
rotation: text rotation angle in degrees
width_factor: text width factor as float
mirror: :attr:`MTextSurrogate.MIRROR_X` to mirror the text horizontal
or :attr:`MTextSurrogate.MIRROR_Y` to mirror the text vertical
layer: layer name as string
color: :ref:`ACI`
"""
MIRROR_NONE = Mirror.NONE
MIRROR_X = Mirror.MIRROR_X
MIRROR_Y = Mirror.MIRROR_Y
def __init__(
self,
text: str,
insert: UVec,
line_spacing: float = 1.5,
align=MTextEntityAlignment.TOP_LEFT,
char_height: float = 1.0,
style="STANDARD",
oblique: float = 0.0,
rotation: float = 0.0,
width_factor: float = 1.0,
mirror=Mirror.NONE,
layer="0",
color: int = const.BYLAYER,
):
self.content: list[str] = text.split("\n")
self.insert = Vec3(insert)
self.line_spacing = float(line_spacing)
assert isinstance(align, MTextEntityAlignment)
self.align = align
self.char_height = float(char_height)
self.style = str(style)
self.oblique = float(oblique) # in degree
self.rotation = float(rotation) # in degree
self.width_factor = float(width_factor)
self.mirror = int(mirror) # renamed to text_generation_flag in ezdxf
self.layer = str(layer)
self.color = int(color)
@property
def line_height(self) -> float:
"""Absolute line spacing in drawing units."""
return self.char_height * self.line_spacing
def render(self, layout: GenericLayoutType) -> None:
"""Render the multi-line content as separated TEXT entities into the
given `layout` instance.
"""
text_lines = self.content
if len(text_lines) > 1:
if self.mirror & const.MIRROR_Y:
text_lines.reverse()
for line_number, text in enumerate(text_lines):
align_point = self._get_align_point(line_number)
layout.add_text(
text,
dxfattribs=self._dxfattribs(align_point),
)
elif len(text_lines) == 1:
layout.add_text(
text_lines[0],
dxfattribs=self._dxfattribs(self.insert),
)
def _get_align_point(self, line_number: int) -> Vec3:
"""Calculate the align-point depending on the line number."""
x = self.insert.x
y = self.insert.y
try:
z = self.insert.z
except IndexError:
z = 0.0
# rotation not respected
if self.align in TOP_ALIGN:
y -= line_number * self.line_height
elif self.align in MIDDLE_ALIGN:
y0 = line_number * self.line_height
full_height = (len(self.content) - 1) * self.line_height
y += (full_height / 2) - y0
else: # BOTTOM ALIGN
y += (len(self.content) - 1 - line_number) * self.line_height
return self._rotate(Vec3(x, y, z))
def _rotate(self, alignpoint: Vec3) -> Vec3:
"""Rotate `alignpoint` around insert-point about rotation degrees."""
dx = alignpoint.x - self.insert.x
dy = alignpoint.y - self.insert.y
beta = math.radians(self.rotation)
x = self.insert.x + dx * math.cos(beta) - dy * math.sin(beta)
y = self.insert.y + dy * math.cos(beta) + dx * math.sin(beta)
return Vec3(round(x, 6), round(y, 6), alignpoint.z)
def _dxfattribs(self, align_point: Vec3) -> dict[str, Any]:
"""Build keyword arguments for TEXT entity creation."""
halign, valign = MAP_MTEXT_ALIGN_TO_FLAGS[self.align]
return {
"insert": align_point,
"align_point": align_point,
"layer": self.layer,
"color": self.color,
"style": self.style,
"height": self.char_height,
"width": self.width_factor,
"text_generation_flag": self.mirror,
"rotation": self.rotation,
"oblique": self.oblique,
"halign": halign,
"valign": valign,
}
@@ -0,0 +1,388 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import cast, Any, Optional, TYPE_CHECKING
import math
import ezdxf
from ezdxf.entities import MText, DXFGraphic, Textstyle
from ezdxf.enums import TextEntityAlignment
# from ezdxf.layouts import BaseLayout
from ezdxf.document import Drawing
from ezdxf.math import Matrix44
from ezdxf.fonts import fonts
from ezdxf.tools import text_layout as tl
from ezdxf.tools.text import MTextContext
from ezdxf.render.abstract_mtext_renderer import AbstractMTextRenderer
if TYPE_CHECKING:
from ezdxf.eztypes import GenericLayoutType
__all__ = ["MTextExplode"]
class FrameRenderer(tl.ContentRenderer):
def __init__(self, attribs: dict, layout: GenericLayoutType):
self.line_attribs = attribs
self.layout = layout
def render(
self,
left: float,
bottom: float,
right: float,
top: float,
m: Matrix44 = None,
) -> None:
pline = self.layout.add_lwpolyline(
[(left, top), (right, top), (right, bottom), (left, bottom)],
close=True,
dxfattribs=self.line_attribs,
)
if m:
pline.transform(m)
def line(
self, x1: float, y1: float, x2: float, y2: float, m: Matrix44 = None
) -> None:
line = self.layout.add_line((x1, y1), (x2, y2), dxfattribs=self.line_attribs)
if m:
line.transform(m)
class ColumnBackgroundRenderer(FrameRenderer):
def __init__(
self,
attribs: dict,
layout: GenericLayoutType,
bg_aci: Optional[int] = None,
bg_true_color: Optional[int] = None,
offset: float = 0,
text_frame: bool = False,
):
super().__init__(attribs, layout)
self.solid_attribs = None
if bg_aci is not None:
self.solid_attribs = dict(attribs)
self.solid_attribs["color"] = bg_aci
elif bg_true_color is not None:
self.solid_attribs = dict(attribs)
self.solid_attribs["true_color"] = bg_true_color
self.offset = offset # background border offset
self.has_text_frame = text_frame
def render(
self,
left: float,
bottom: float,
right: float,
top: float,
m: Optional[Matrix44] = None,
) -> None:
# Important: this is not a clipping box, it is possible to
# render anything outside of the given borders!
offset = self.offset
left -= offset
right += offset
top += offset
bottom -= offset
if self.solid_attribs is not None:
solid = self.layout.add_solid(
# SOLID! swap last two vertices:
[(left, top), (right, top), (left, bottom), (right, bottom)],
dxfattribs=self.solid_attribs,
)
if m:
solid.transform(m)
if self.has_text_frame:
super().render(left, bottom, right, top, m)
class TextRenderer(FrameRenderer):
"""Text content renderer."""
def __init__(
self,
text: str,
text_attribs: dict,
line_attribs: dict,
layout: GenericLayoutType,
):
super().__init__(line_attribs, layout)
self.text = text
self.text_attribs = text_attribs
def render(
self,
left: float,
bottom: float,
right: float,
top: float,
m: Optional[Matrix44] = None,
):
"""Create/render the text content"""
text = self.layout.add_text(self.text, dxfattribs=self.text_attribs)
text.set_placement((left, bottom), align=TextEntityAlignment.LEFT)
if m:
text.transform(m)
# todo: replace by fonts.get_entity_font_face()
def get_font_face(entity: DXFGraphic, doc=None) -> fonts.FontFace:
"""Returns the :class:`~ezdxf.tools.fonts.FontFace` defined by the
associated text style. Returns the default font face if the `entity` does
not have or support the DXF attribute "style".
Pass a DXF document as argument `doc` to resolve text styles for virtual
entities which are not assigned to a DXF document. The argument `doc`
always overrides the DXF document to which the `entity` is assigned to.
"""
if entity.doc and doc is None:
doc = entity.doc
assert doc is not None, "valid DXF document required"
style_name = ""
# This works also for entities which do not support "style",
# where style_name = entity.dxf.get("style") would fail.
if entity.dxf.is_supported("style"):
style_name = entity.dxf.style
font_face = fonts.FontFace()
if style_name and doc is not None:
style = cast(Textstyle, doc.styles.get(style_name))
family, italic, bold = style.get_extended_font_data()
if family:
text_style = "Italic" if italic else "Regular"
text_weight = 700 if bold else 400
font_face = fonts.FontFace(
family=family, style=text_style, weight=text_weight
)
else:
ttf = style.dxf.font
if ttf:
font_face = fonts.get_font_face(ttf)
return font_face
def get_color_attribs(ctx: MTextContext) -> dict:
attribs = {"color": ctx.aci}
if ctx.rgb is not None:
attribs["true_color"] = ezdxf.rgb2int(ctx.rgb)
return attribs
def make_bg_renderer(mtext: MText, layout: GenericLayoutType):
attribs = get_base_attribs(mtext)
dxf = mtext.dxf
bg_fill = dxf.get("bg_fill", 0)
bg_aci = None
bg_true_color = None
has_text_frame = False
offset = 0
if bg_fill:
# The fill scale is a multiple of the initial char height and
# a scale of 1, fits exact the outer border
# of the column -> offset = 0
offset = dxf.char_height * (dxf.get("box_fill_scale", 1.5) - 1)
if bg_fill & ezdxf.const.MTEXT_BG_COLOR:
if dxf.hasattr("bg_fill_color"):
bg_aci = dxf.bg_fill_color
if dxf.hasattr("bg_fill_true_color"):
bg_aci = None
bg_true_color = dxf.bg_fill_true_color
if (bg_fill & 3) == 3: # canvas color = bit 0 and 1 set
# can not detect canvas color from DXF document!
# do not draw any background:
bg_aci = None
bg_true_color = None
if bg_fill & ezdxf.const.MTEXT_TEXT_FRAME:
has_text_frame = True
return ColumnBackgroundRenderer(
attribs,
layout,
bg_aci=bg_aci,
bg_true_color=bg_true_color,
offset=offset,
text_frame=has_text_frame,
)
def get_base_attribs(mtext: MText) -> dict:
dxf = mtext.dxf
attribs = {
"layer": dxf.layer,
"color": dxf.color,
}
return attribs
class MTextExplode(AbstractMTextRenderer):
"""The :class:`MTextExplode` class is a tool to disassemble MTEXT entities
into single line TEXT entities and additional LINE entities if required to
emulate strokes.
The `layout` argument defines the target layout for "exploded" parts of the
MTEXT entity. Use argument `doc` if the target layout has no DXF document assigned
like virtual layouts. The `spacing_factor` argument is an advanced tuning parameter
to scale the size of space chars.
"""
def __init__(
self,
layout: GenericLayoutType,
doc: Optional[Drawing] = None,
spacing_factor: float = 1.0,
):
super().__init__()
self.layout: GenericLayoutType = layout
self._doc = doc
# scale the width of spaces by this factor:
self._spacing_factor = float(spacing_factor)
self._required_text_styles: dict[str, fonts.FontFace] = {}
self.current_base_attribs: dict[str, Any] = dict()
# Implementation of required AbstractMTextRenderer methods and overrides:
def layout_engine(self, mtext: MText) -> tl.Layout:
self.current_base_attribs = get_base_attribs(mtext)
return super().layout_engine(mtext)
def word(self, text: str, ctx: MTextContext) -> tl.ContentCell:
line_attribs = dict(self.current_base_attribs or {})
line_attribs.update(get_color_attribs(ctx))
text_attribs = dict(line_attribs)
text_attribs.update(self.get_text_attribs(ctx))
return tl.Text(
width=self.get_font(ctx).text_width(text),
height=ctx.cap_height,
valign=tl.CellAlignment(ctx.align),
stroke=self.get_stroke(ctx),
renderer=TextRenderer(text, text_attribs, line_attribs, self.layout),
)
def fraction(self, data: tuple, ctx: MTextContext) -> tl.ContentCell:
upr, lwr, type_ = data
if type_:
return tl.Fraction(
top=self.word(upr, ctx),
bottom=self.word(lwr, ctx),
stacking=self.get_stacking(type_),
# renders just the divider line:
renderer=FrameRenderer(self.current_base_attribs, self.layout),
)
else:
return self.word(upr, ctx)
def get_font_face(self, mtext: MText) -> fonts.FontFace:
return get_font_face(mtext)
def make_bg_renderer(self, mtext: MText) -> tl.ContentRenderer:
return make_bg_renderer(mtext, self.layout)
# Implementation details of MTextExplode:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.finalize()
def mtext_exploded_text_style(self, font_face: fonts.FontFace) -> str:
style = 0
if font_face.is_bold:
style += 1
if font_face.is_italic:
style += 2
style_str = str(style) if style > 0 else ""
# BricsCAD naming convention for exploded MTEXT styles:
text_style = f"MtXpl_{font_face.family}" + style_str
self._required_text_styles[text_style] = font_face
return text_style
def get_font(self, ctx: MTextContext) -> fonts.AbstractFont:
ttf = fonts.find_font_file_name(ctx.font_face)
key = (ttf, ctx.cap_height, ctx.width_factor)
font = self._font_cache.get(key)
if font is None:
font = fonts.make_font(ttf, ctx.cap_height, ctx.width_factor)
self._font_cache[key] = font
return font
def get_text_attribs(self, ctx: MTextContext) -> dict:
attribs = {
"height": ctx.cap_height,
"style": self.mtext_exploded_text_style(ctx.font_face),
}
if not math.isclose(ctx.width_factor, 1.0):
attribs["width"] = ctx.width_factor
if abs(ctx.oblique) > 1e-6:
attribs["oblique"] = ctx.oblique
return attribs
def explode(self, mtext: MText, destroy=True):
"""Explode `mtext` and destroy the source entity if argument `destroy`
is ``True``.
"""
align = tl.LayoutAlignment(mtext.dxf.attachment_point)
layout_engine = self.layout_engine(mtext)
layout_engine.place(align=align)
layout_engine.render(mtext.ucs().matrix)
if destroy:
mtext.destroy()
def finalize(self):
"""Create required text styles. This method is called automatically if
the class is used as context manager. This method does not work with virtual
layouts if no document was assigned at initialization!
"""
doc = self._doc
if doc is None:
doc = self.layout.doc
if doc is None:
raise ezdxf.DXFValueError(
"DXF document required, finalize() does not work with virtual layouts "
"if no document was assigned at initialization."
)
text_styles = doc.styles
for style in self.make_required_style_table_entries():
try:
text_styles.add_entry(style)
except ezdxf.DXFTableEntryError:
pass
def make_required_style_table_entries(self) -> list[Textstyle]:
def ttf_path(font_face: fonts.FontFace) -> str:
ttf = font_face.filename
if not ttf:
ttf = fonts.find_font_file_name(font_face)
else:
# remapping SHX replacement fonts to SHX fonts,
# like "txt_____.ttf" to "TXT.SHX":
shx = fonts.map_ttf_to_shx(ttf)
if shx:
ttf = shx
return ttf
text_styles: list[Textstyle] = []
for name, font_face in self._required_text_styles.items():
ttf = ttf_path(font_face)
style = Textstyle.new(dxfattribs={
"name": name,
"font": ttf,
})
if not ttf.endswith(".SHX"):
style.set_extended_font_data(
font_face.family,
italic=font_face.is_italic,
bold=font_face.is_bold,
)
text_styles.append(style)
return text_styles
@@ -0,0 +1,518 @@
# Copyright (c) 2020-2023, Manfred Moitzi
# License: MIT License
# type: ignore
from __future__ import annotations
from typing import Optional
import logging
import os
import platform
import shutil
import subprocess
import tempfile
import time
from contextlib import contextmanager
from pathlib import Path
import ezdxf
from ezdxf.document import Drawing
from ezdxf.lldxf.validator import (
is_dxf_file,
dxf_info,
is_binary_dxf_file,
dwg_version,
)
logger = logging.getLogger("ezdxf")
class ODAFCError(IOError):
pass
class UnknownODAFCError(ODAFCError):
pass
class ODAFCNotInstalledError(ODAFCError):
pass
class UnsupportedFileFormat(ODAFCError):
pass
class UnsupportedPlatform(ODAFCError):
pass
class UnsupportedVersion(ODAFCError):
pass
VERSION_MAP = {
"R12": "ACAD12",
"R13": "ACAD13",
"R14": "ACAD14",
"R2000": "ACAD2000",
"R2004": "ACAD2004",
"R2007": "ACAD2007",
"R2010": "ACAD2010",
"R2013": "ACAD2013",
"R2018": "ACAD2018",
"AC1004": "ACAD9",
"AC1006": "ACAD10",
"AC1009": "ACAD12",
"AC1012": "ACAD13",
"AC1014": "ACAD14",
"AC1015": "ACAD2000",
"AC1018": "ACAD2004",
"AC1021": "ACAD2007",
"AC1024": "ACAD2010",
"AC1027": "ACAD2013",
"AC1032": "ACAD2018",
}
VALID_VERSIONS = {
"ACAD9",
"ACAD10",
"ACAD12",
"ACAD13",
"ACAD14",
"ACAD2000",
"ACAD2004",
"ACAD2007",
"ACAD2010",
"ACAD2013",
"ACAD2018",
}
WINDOWS = "Windows"
LINUX = "Linux"
DARWIN = "Darwin"
def get_win_exec_path() -> str:
return ezdxf.options.get("odafc-addon", "win_exec_path").strip('"')
def get_unix_exec_path() -> str:
return ezdxf.options.get("odafc-addon", "unix_exec_path").strip('"')
def is_installed() -> bool:
"""Returns ``True`` if the ODAFileConverter is installed."""
if platform.system() in (LINUX, DARWIN):
unix_exec_path = get_unix_exec_path()
if unix_exec_path and Path(unix_exec_path).is_file():
return True
return shutil.which("ODAFileConverter") is not None
# Windows:
return os.path.exists(get_win_exec_path())
def map_version(version: str) -> str:
return VERSION_MAP.get(version.upper(), version.upper())
def readfile(
filename: str | os.PathLike, version: Optional[str] = None, *, audit: bool = False
) -> Drawing:
"""Uses an installed `ODA File Converter`_ to convert a DWG/DXB/DXF file
into a temporary DXF file and load this file by `ezdxf`.
Args:
filename: file to load by ODA File Converter
version: load file as specific DXF version, by default the same version
as the source file or if not detectable the latest by `ezdxf`
supported version.
audit: audit source file before loading
Raises:
FileNotFoundError: source file not found
odafc.UnknownODAFCError: conversion failed for unknown reasons
odafc.UnsupportedVersion: invalid DWG version specified
odafc.UnsupportedFileFormat: unsupported file extension
odafc.ODAFCNotInstalledError: ODA File Converter not installed
"""
infile = Path(filename).absolute()
if not infile.is_file():
raise FileNotFoundError(f"No such file: '{infile}'")
if isinstance(version, str):
version = map_version(version)
else:
version = _detect_version(filename)
with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir:
args = _odafc_arguments(
infile.name,
str(infile.parent),
tmp_dir,
output_format="DXF",
version=version,
audit=audit,
)
_execute_odafc(args)
out_file = Path(tmp_dir) / infile.with_suffix(".dxf").name
if out_file.exists():
doc = ezdxf.readfile(str(out_file))
doc.filename = str(infile.with_suffix(".dxf"))
return doc
raise UnknownODAFCError("Failed to convert file: Unknown Error")
def export_dwg(
doc: Drawing,
filename: str | os.PathLike,
version: Optional[str] = None,
*,
audit: bool = False,
replace: bool = False,
) -> None:
"""Uses an installed `ODA File Converter`_ to export the DXF document `doc`
as a DWG file.
A temporary DXF file will be created and converted to DWG by the
ODA File Converter. If `version` is not specified the DXF version of the
source document is used.
Args:
doc: `ezdxf` DXF document as :class:`~ezdxf.drawing.Drawing` object
filename: output DWG filename, the extension will be set to ".dwg"
version: DWG version to export, by default the same version as
the source document.
audit: audit source file by ODA File Converter at exporting
replace: replace existing DWG file if ``True``
Raises:
FileExistsError: target file already exists, and argument `replace` is
``False``
FileNotFoundError: parent directory of target file does not exist
odafc.UnknownODAFCError: exporting DWG failed for unknown reasons
odafc.ODAFCNotInstalledError: ODA File Converter not installed
"""
if version is None:
version = doc.dxfversion
export_version = VERSION_MAP[version]
dwg_file = Path(filename).absolute()
out_folder = Path(dwg_file.parent)
if dwg_file.exists():
if replace:
dwg_file.unlink()
else:
raise FileExistsError(f"File already exists: {dwg_file}")
if out_folder.exists():
with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir:
dxf_file = Path(tmp_dir) / dwg_file.with_suffix(".dxf").name
# Save DXF document
old_filename = doc.filename
doc.saveas(dxf_file)
doc.filename = old_filename
arguments = _odafc_arguments(
dxf_file.name,
tmp_dir,
str(out_folder),
output_format="DWG",
version=export_version,
audit=audit,
)
_execute_odafc(arguments)
else:
raise FileNotFoundError(f"No such file or directory: '{str(out_folder)}'")
def convert(
source: str | os.PathLike,
dest: str | os.PathLike = "",
*,
version="R2018",
audit=True,
replace=False,
):
"""Convert `source` file to `dest` file.
The file extension defines the target format
e.g. :code:`convert("test.dxf", "Test.dwg")` converts the source file to a
DWG file.
If `dest` is an empty string the conversion depends on the source file
format and is DXF to DWG or DWG to DXF.
To convert DXF to DXF an explicit destination filename is required:
:code:`convert("r12.dxf", "r2013.dxf", version="R2013")`
Args:
source: source file
dest: destination file, an empty string uses the source filename with
the extension of the target format e.g. "test.dxf" -> "test.dwg"
version: output DXF/DWG version e.g. "ACAD2018", "R2018", "AC1032"
audit: audit files
replace: replace existing destination file
Raises:
FileNotFoundError: source file or destination folder does not exist
FileExistsError: destination file already exists and argument `replace`
is ``False``
odafc.UnsupportedVersion: invalid DXF version specified
odafc.UnsupportedFileFormat: unsupported file extension
odafc.UnknownODAFCError: conversion failed for unknown reasons
odafc.ODAFCNotInstalledError: ODA File Converter not installed
"""
version = map_version(version)
if version not in VALID_VERSIONS:
raise UnsupportedVersion(f"Invalid version: '{version}'")
src_path = Path(source).expanduser().absolute()
if not src_path.exists():
raise FileNotFoundError(f"Source file not found: '{source}'")
if dest:
dest_path = Path(dest)
else:
ext = src_path.suffix.lower()
if ext == ".dwg":
dest_path = src_path.with_suffix(".dxf")
elif ext == ".dxf":
dest_path = src_path.with_suffix(".dwg")
else:
raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'")
if dest_path.exists() and not replace:
raise FileExistsError(f"Target file already exists: '{dest_path}'")
parent_dir = dest_path.parent
if not parent_dir.exists() or not parent_dir.is_dir():
# Cannot copy result to destination folder!
raise FileNotFoundError(f"Destination folder does not exist: '{parent_dir}'")
ext = dest_path.suffix
fmt = ext.upper()[1:]
if fmt not in ("DXF", "DWG"):
raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'")
with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir:
arguments = _odafc_arguments(
src_path.name,
in_folder=str(src_path.parent),
out_folder=str(tmp_dir),
output_format=fmt,
version=version,
audit=audit,
)
_execute_odafc(arguments)
result = list(Path(tmp_dir).iterdir())
if result:
try:
shutil.move(result[0], dest_path)
except IOError:
shutil.copy(result[0], dest_path)
else:
UnknownODAFCError(f"Unknown error: no {fmt} file was created")
def _detect_version(path: str) -> str:
"""Returns the DXF/DWG version of file `path` as ODAFC compatible version
string.
Raises:
odafc.UnsupportedVersion: unknown or unsupported DWG version
odafc.UnsupportedFileFormat; unsupported file extension
"""
version = "ACAD2018"
ext = os.path.splitext(path)[1].lower()
if ext == ".dxf":
if is_binary_dxf_file(path):
pass
elif is_dxf_file(path):
with open(path, "rt") as fp:
info = dxf_info(fp)
version = VERSION_MAP[info.version]
elif ext == ".dwg":
version = dwg_version(path)
if version is None:
raise UnsupportedVersion("Unknown or unsupported DWG version.")
else:
raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'")
return map_version(version)
def _odafc_arguments(
filename: str,
in_folder: str,
out_folder: str,
output_format: str = "DXF",
version: str = "ACAD2013",
audit: bool = False,
) -> list[str]:
"""ODA File Converter command line format:
ODAFileConverter "Input Folder" "Output Folder" version type recurse audit [filter]
- version: output version: "ACAD9" - "ACAD2018"
- type: output file type: "DWG", "DXF", "DXB"
- recurse: recurse Input Folder: "0" or "1"
- audit: audit each file: "0" or "1"
- optional Input files filter: default "*.DWG,*.DXF"
"""
recurse = "0"
audit_str = "1" if audit else "0"
return [
in_folder,
out_folder,
version,
output_format,
recurse,
audit_str,
filename,
]
def _get_odafc_path(system: str) -> str:
"""Get ODAFC application path.
Raises:
odafc.ODAFCNotInstalledError: ODA File Converter not installed
"""
# on Linux and Darwin check if UNIX_EXEC_PATH is set and exist and
# return this path as exec path.
# This may help if the "which" command can not find the "ODAFileConverter"
# command and also adds support for AppImages provided by ODA.
unix_exec_path = get_unix_exec_path()
if system != WINDOWS and unix_exec_path:
if Path(unix_exec_path).is_file():
return unix_exec_path
else:
logger.warning(
f"command '{unix_exec_path}' not found, using 'ODAFileConverter'"
)
path = shutil.which("ODAFileConverter")
if not path and system == WINDOWS:
path = get_win_exec_path()
if not Path(path).is_file():
path = None
if not path:
raise ODAFCNotInstalledError(
f"Could not find ODAFileConverter in the path. "
f"Install application from https://www.opendesign.com/guestfiles/oda_file_converter"
)
return path
@contextmanager
def _linux_dummy_display():
"""See xvbfwrapper library for a more feature complete xvfb interface."""
if shutil.which("Xvfb"):
display = f":{os.getpid()}" # Each process has its own screen id
proc = subprocess.Popen(
["Xvfb", display, "-screen", "0", "800x600x24"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(0.1)
yield display
try:
proc.terminate()
proc.wait()
except OSError:
pass
else:
logger.warning(f"Install xvfb to prevent the ODAFileConverter GUI from opening")
yield os.environ["DISPLAY"]
def _run_with_no_gui(
system: str, command: str, arguments: list[str]
) -> subprocess.Popen:
"""Execute ODAFC application without launching the GUI.
Args:
system: "Linux", "Windows" or "Darwin"
command: application to execute
arguments: ODAFC argument list
Raises:
odafc.UnsupportedPlatform: for unsupported platforms
odafc.ODAFCNotInstalledError: ODA File Converter not installed
"""
if system == LINUX:
with _linux_dummy_display() as display:
env = os.environ.copy()
env["DISPLAY"] = display
proc = subprocess.run(
[command] + arguments, text=True, capture_output=True, env=env
)
elif system == DARWIN:
# TODO: unknown how to prevent the GUI from appearing on macOS
proc = subprocess.run([command] + arguments, text=True, capture_output=True)
elif system == WINDOWS:
# New code from George-Jiang to solve the GUI pop-up problem
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags = (
subprocess.CREATE_NEW_CONSOLE | subprocess.STARTF_USESHOWWINDOW
)
startupinfo.wShowWindow = subprocess.SW_HIDE
proc = subprocess.Popen(
[command] + arguments,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
startupinfo=startupinfo,
)
proc.wait()
else:
# The OpenDesign Alliance only provides executables for Linux, macOS
# and Windows:
raise UnsupportedPlatform(f"Unsupported platform: {system}")
return proc
def _odafc_failed(system: str, returncode: int, stderr: str) -> bool:
# changed v0.18.1, see https://github.com/mozman/ezdxf/issues/707
stderr = stderr.strip()
if system == LINUX:
# ODAFileConverter *always* crashes on Linux even if the output was successful
return stderr != "" and stderr != "Quit (core dumped)"
elif returncode != 0:
return True
else:
return stderr != ""
def _execute_odafc(arguments: list[str]) -> Optional[bytes]:
"""Execute ODAFC application.
Args:
arguments: ODAFC argument list
Raises:
odafc.ODAFCNotInstalledError: ODA File Converter not installed
odafc.UnknownODAFCError: execution failed for unknown reasons
odafc.UnsupportedPlatform: for unsupported platforms
"""
logger.debug(f"Running ODAFileConverter with arguments: {arguments}")
system = platform.system()
oda_fc = _get_odafc_path(system)
proc = _run_with_no_gui(system, oda_fc, arguments)
returncode = proc.returncode
if system == WINDOWS:
stdout = proc.stdout.read().decode("utf-8")
stderr = proc.stderr.read().decode("utf-8")
else:
stdout = proc.stdout
stderr = proc.stderr
if _odafc_failed(system, returncode, stderr):
msg = (
f"ODA File Converter failed: return code = {returncode}.\n"
f"stdout: {stdout}\nstderr: {stderr}"
)
logger.debug(msg)
raise UnknownODAFCError(msg)
return stdout
@@ -0,0 +1,315 @@
# Copyright (c) 2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Union, Iterable, Sequence, Optional
from pathlib import Path
import enum
import platform
import shutil
import subprocess
from uuid import uuid4
import tempfile
import ezdxf
from ezdxf.math import Matrix44, UVec, Vec3, Vec2
from ezdxf.render import MeshBuilder, MeshTransformer
from ezdxf.addons import meshex
class Operation(enum.Enum):
union = 0
difference = 1
intersection = 2
CMD = "openscad"
UNION = Operation.union
DIFFERENCE = Operation.difference
INTERSECTION = Operation.intersection
def get_openscad_path() -> str:
if platform.system() in ("Linux", "Darwin"):
return CMD
else:
return ezdxf.options.get("openscad-addon", "win_exec_path").strip('"')
def is_installed() -> bool:
"""Returns ``True`` if OpenSCAD is installed.
Searches on Windows the path stored in the options as "win_exec_path" in section
"[openscad-addon]" which is "C:\\Program Files\\OpenSCAD\\openscad.exe" by default.
Searches the "openscad" command on Linux and macOS.
"""
if platform.system() in ("Linux", "Darwin"):
return shutil.which(CMD) is not None
return Path(get_openscad_path()).exists()
def run(script: str, exec_path: Optional[str] = None) -> MeshTransformer:
"""Executes the given `script` by OpenSCAD and returns the result mesh as
:class:`~ezdxf.render.MeshTransformer`.
Args:
script: the OpenSCAD script as string
exec_path: path to the executable as string or ``None`` to use the
default installation path
"""
if exec_path is None:
exec_path = get_openscad_path()
workdir = Path(tempfile.gettempdir())
uuid = str(uuid4())
# The OFF format is more compact than the default STL format
off_path = workdir / f"ezdxf_{uuid}.off"
scad_path = workdir / f"ezdxf_{uuid}.scad"
scad_path.write_text(script)
subprocess.call(
[
exec_path,
"--quiet",
"-o",
str(off_path),
str(scad_path),
]
)
# Remove the OpenSCAD temp file:
scad_path.unlink(missing_ok=True)
new_mesh = MeshTransformer()
# Import the OpenSCAD result from OFF file:
if off_path.exists():
new_mesh = meshex.off_loads(off_path.read_text())
# Remove the OFF temp file:
off_path.unlink(missing_ok=True)
return new_mesh
def str_matrix44(m: Matrix44) -> str:
# OpenSCAD uses column major order!
import numpy as np
def cleanup(values: Iterable) -> Iterable:
for value in values:
if isinstance(value, np.float64):
yield float(value)
else:
yield value
s = ", ".join([str(list(cleanup(c))) for c in m.columns()])
return f"[{s}]"
def str_polygon(
path: Iterable[UVec],
holes: Optional[Sequence[Iterable[UVec]]] = None,
) -> str:
"""Returns a ``polygon()`` command as string. This is a 2D command, all
z-axis values of the input vertices are ignored and all paths and holes
are closed automatically.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Using_the_2D_Subsystem#polygon
Args:
path: exterior path
holes: a sequences of one or more holes as vertices
"""
def add_vertices(vertices):
index = len(points)
indices = []
vlist = Vec2.list(vertices)
if not vlist[0].isclose(vlist[-1]):
vlist.append(vlist[0])
for v in vlist:
indices.append(index)
points.append(f" [{v.x:g}, {v.y:g}],")
index += 1
return indices
points: list[str] = []
paths = [add_vertices(path)]
if holes is not None:
for hole in holes:
paths.append(add_vertices(hole))
lines = ["polygon(points = ["]
lines.extend(points)
lines.append("],")
if holes is not None:
lines.append("paths = [")
for indices in paths:
lines.append(f" {str(indices)},")
lines.append("],")
lines.append("convexity = 10);")
return "\n".join(lines)
class Script:
def __init__(self) -> None:
self.data: list[str] = []
def add(self, data: str) -> None:
"""Add a string."""
self.data.append(data)
def add_polyhedron(self, mesh: MeshBuilder) -> None:
"""Add `mesh` as ``polyhedron()`` command.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Primitive_Solids#polyhedron
"""
self.add(meshex.scad_dumps(mesh))
def add_polygon(
self,
path: Iterable[UVec],
holes: Optional[Sequence[Iterable[UVec]]] = None,
) -> None:
"""Add a ``polygon()`` command. This is a 2D command, all
z-axis values of the input vertices are ignored and all paths and holes
are closed automatically.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Using_the_2D_Subsystem#polygon
Args:
path: exterior path
holes: a sequence of one or more holes as vertices, or ``None`` for no holes
"""
self.add(str_polygon(path, holes))
def add_multmatrix(self, m: Matrix44) -> None:
"""Add a transformation matrix of type :class:`~ezdxf.math.Matrix44` as
``multmatrix()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#multmatrix
"""
self.add(f"multmatrix(m = {str_matrix44(m)})") # no pending ";"
def add_translate(self, v: UVec) -> None:
"""Add a ``translate()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#translate
Args:
v: translation vector
"""
vec = Vec3(v)
self.add(f"translate(v = [{vec.x:g}, {vec.y:g}, {vec.z:g}])")
def add_rotate(self, ax: float, ay: float, az: float) -> None:
"""Add a ``rotation()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#rotate
Args:
ax: rotation about the x-axis in degrees
ay: rotation about the y-axis in degrees
az: rotation about the z-axis in degrees
"""
self.add(f"rotate(a = [{ax:g}, {ay:g}, {az:g}])")
def add_rotate_about_axis(self, a: float, v: UVec) -> None:
"""Add a ``rotation()`` operation about the given axis `v`.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#rotate
Args:
a: rotation angle about axis `v` in degrees
v: rotation axis as :class:`ezdxf.math.UVec` object
"""
vec = Vec3(v)
self.add(f"rotate(a = {a:g}, v = [{vec.x:g}, {vec.y:g}, {vec.z:g}])")
def add_scale(self, sx: float, sy: float, sz: float) -> None:
"""Add a ``scale()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#scale
Args:
sx: scaling factor for the x-axis
sy: scaling factor for the y-axis
sz: scaling factor for the z-axis
"""
self.add(f"scale(v = [{sx:g}, {sy:g}, {sz:g}])")
def add_resize(
self,
nx: float,
ny: float,
nz: float,
auto: Optional[Union[bool, tuple[bool, bool, bool]]] = None,
) -> None:
"""Add a ``resize()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#resize
Args:
nx: new size in x-axis
ny: new size in y-axis
nz: new size in z-axis
auto: If the `auto` argument is set to ``True``, the operation
auto-scales any 0-dimensions to match. Set the `auto` argument
as a 3-tuple of bool values to auto-scale individual axis.
"""
main = f"resize(newsize = [{nx:g}, {ny:g}, {nz:g}]"
if auto is None:
self.add(main + ")")
return
elif isinstance(auto, bool):
flags = str(auto).lower()
else:
flags = ", ".join([str(a).lower() for a in auto])
flags = f"[{flags}]"
self.add(main + f", auto = {flags})")
def add_mirror(self, v: UVec) -> None:
"""Add a ``mirror()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#mirror
Args:
v: the normal vector of a plane intersecting the origin through
which to mirror the object
"""
n = Vec3(v).normalize()
self.add(f"mirror(v = [{n.x:g}, {n.y:g}, {n.z:g}])")
def get_string(self) -> str:
"""Returns the OpenSCAD build script."""
return "\n".join(self.data)
def boolean_operation(op: Operation, mesh1: MeshBuilder, mesh2: MeshBuilder) -> str:
"""Returns an `OpenSCAD`_ script to apply the given boolean operation to the
given meshes.
The supported operations are:
- UNION
- DIFFERENCE
- INTERSECTION
"""
assert isinstance(op, Operation), "enum of type Operation expected"
script = Script()
script.add(f"{op.name}() {{")
script.add_polyhedron(mesh1)
script.add_polyhedron(mesh2)
script.add("}")
return script.get_string()
@@ -0,0 +1,444 @@
# License
# Copyright (c) 2011 Evan Wallace (http://madebyevan.com/), under the MIT license.
# Python port Copyright (c) 2012 Tim Knip (http://www.floorplanner.com), under the MIT license.
# Additions by Alex Pletzer (Pennsylvania State University)
# Integration as ezdxf add-on, Copyright (c) 2020, Manfred Moitzi, MIT License.
from __future__ import annotations
from typing import Optional
from ezdxf.math import Vec3, best_fit_normal, normal_vector_3p
from ezdxf.render import MeshVertexMerger, MeshBuilder, MeshTransformer
# Implementation Details
# ----------------------
#
# All CSG operations are implemented in terms of two functions, clip_to() and
# invert(), which remove parts of a BSP tree inside another BSP tree and swap
# solid and empty space, respectively. To find the union of a and b, we
# want to remove everything in a inside b and everything in b inside a,
# then combine polygons from a and b into one solid:
#
# a.clip_to(b)
# b.clip_to(a)
# a.build(b.all_polygons())
#
# The only tricky part is handling overlapping coplanar polygons in both trees.
# The code above keeps both copies, but we need to keep them in one tree and
# remove them in the other tree. To remove them from b we can clip the
# inverse of b against a. The code for union now looks like this:
#
# a.clip_to(b)
# b.clip_to(a)
# b.invert()
# b.clip_to(a)
# b.invert()
# a.build(b.all_polygons())
#
# Subtraction and intersection naturally follow from set operations. If
# union is A | B, subtraction is A - B = ~(~A | B) and intersection is
# A & B = ~(~A | ~B) where '~' is the complement operator.
__all__ = ["CSG"]
COPLANAR = 0 # all the vertices are within EPSILON distance from plane
FRONT = 1 # all the vertices are in front of the plane
BACK = 2 # all the vertices are at the back of the plane
SPANNING = 3 # some vertices are in front, some in the back
PLANE_EPSILON = 1e-5 # Tolerance used by split_polygon() to decide if a point is on the plane.
class Plane:
"""Represents a plane in 3D space."""
__slots__ = ("normal", "w")
def __init__(self, normal: Vec3, w: float):
self.normal = normal
# w is the (perpendicular) distance of the plane from (0, 0, 0)
self.w = w
@classmethod
def from_points(cls, a: Vec3, b: Vec3, c: Vec3) -> Plane:
n = normal_vector_3p(a, b, c)
return Plane(n, n.dot(a))
def clone(self) -> Plane:
return Plane(self.normal, self.w)
def flip(self) -> None:
self.normal = -self.normal
self.w = -self.w
def __repr__(self) -> str:
return f"Plane({self.normal}, {self.w})"
def split_polygon(
self,
polygon: "Polygon",
coplanar_front: list[Polygon],
coplanar_back: list[Polygon],
front: list[Polygon],
back: list[Polygon],
) -> None:
"""
Split `polygon` by this plane if needed, then put the polygon or polygon
fragments in the appropriate lists. Coplanar polygons go into either
`coplanarFront` or `coplanarBack` depending on their orientation with
respect to this plane. Polygons in front or in back of this plane go into
either `front` or `back`
"""
polygon_type = 0
vertex_types = []
vertices = polygon.vertices
meshid = polygon.meshid # mesh ID of the associated mesh
# Classify each point as well as the entire polygon into one of four classes:
# COPLANAR, FRONT, BACK, SPANNING = FRONT + BACK
for vertex in vertices:
distance = self.normal.dot(vertex) - self.w
if distance < -PLANE_EPSILON:
vertex_type = BACK
elif distance > PLANE_EPSILON:
vertex_type = FRONT
else:
vertex_type = COPLANAR
polygon_type |= vertex_type
vertex_types.append(vertex_type)
# Put the polygon in the correct list, splitting it when necessary.
if polygon_type == COPLANAR:
if self.normal.dot(polygon.plane.normal) > 0:
coplanar_front.append(polygon)
else:
coplanar_back.append(polygon)
elif polygon_type == FRONT:
front.append(polygon)
elif polygon_type == BACK:
back.append(polygon)
elif polygon_type == SPANNING:
front_vertices = []
back_vertices = []
len_vertices = len(vertices)
for index in range(len_vertices):
next_index = (index + 1) % len_vertices
vertex_type = vertex_types[index]
next_vertex_type = vertex_types[next_index]
vertex = vertices[index]
next_vertex = vertices[next_index]
if vertex_type != BACK: # FRONT or COPLANAR
front_vertices.append(vertex)
if vertex_type != FRONT: # BACK or COPLANAR
back_vertices.append(vertex)
if (vertex_type | next_vertex_type) == SPANNING:
interpolation_weight = (
self.w - self.normal.dot(vertex)
) / self.normal.dot(next_vertex - vertex)
plane_intersection_point = vertex.lerp(
next_vertex, interpolation_weight
)
front_vertices.append(plane_intersection_point)
back_vertices.append(plane_intersection_point)
if len(front_vertices) >= 3:
front.append(Polygon(front_vertices, meshid=meshid))
if len(back_vertices) >= 3:
back.append(Polygon(back_vertices, meshid=meshid))
class Polygon:
"""
Represents a convex polygon. The vertices used to initialize a polygon must
be coplanar and form a convex loop, the `meshid` argument associates a polygon
to a mesh.
Args:
vertices: polygon vertices as :class:`Vec3` objects
meshid: id associated mesh
"""
__slots__ = ("vertices", "plane", "meshid")
def __init__(self, vertices: list[Vec3], meshid: int = 0):
self.vertices = vertices
try:
normal = normal_vector_3p(vertices[0], vertices[1], vertices[2])
except ZeroDivisionError: # got three colinear vertices
normal = best_fit_normal(vertices)
self.plane = Plane(normal, normal.dot(vertices[0]))
# number of mesh, this polygon is associated to
self.meshid = meshid
def clone(self) -> Polygon:
return Polygon(list(self.vertices), meshid=self.meshid)
def flip(self) -> None:
self.vertices.reverse()
self.plane.flip()
def __repr__(self) -> str:
v = ", ".join(repr(v) for v in self.vertices)
return f"Polygon([{v}], mesh={self.meshid})"
class BSPNode:
"""
Holds a node in a BSP tree. A BSP tree is built from a collection of polygons
by picking a polygon to split along. That polygon (and all other coplanar
polygons) are added directly to that node and the other polygons are added to
the front and/or back subtrees. This is not a leafy BSP tree since there is
no distinction between internal and leaf nodes.
"""
__slots__ = ("plane", "front", "back", "polygons")
def __init__(self, polygons: Optional[list[Polygon]] = None):
self.plane: Optional[Plane] = None
self.front: Optional[BSPNode] = None
self.back: Optional[BSPNode] = None
self.polygons: list[Polygon] = []
if polygons:
self.build(polygons)
def clone(self) -> BSPNode:
node = BSPNode()
if self.plane:
node.plane = self.plane.clone()
if self.front:
node.front = self.front.clone()
if self.back:
node.back = self.back.clone()
node.polygons = [p.clone() for p in self.polygons]
return node
def invert(self) -> None:
"""Convert solid space to empty space and empty space to solid space."""
for poly in self.polygons:
poly.flip()
assert self.plane is not None
self.plane.flip()
if self.front:
self.front.invert()
if self.back:
self.back.invert()
self.front, self.back = self.back, self.front
def clip_polygons(self, polygons: list[Polygon]) -> list[Polygon]:
"""Recursively remove all polygons in `polygons` that are inside this
BSP tree.
"""
if self.plane is None:
return polygons[:]
front: list[Polygon] = []
back: list[Polygon] = []
for polygon in polygons:
self.plane.split_polygon(polygon, front, back, front, back)
if self.front:
front = self.front.clip_polygons(front)
if self.back:
back = self.back.clip_polygons(back)
else:
back = []
front.extend(back)
return front
def clip_to(self, bsp: BSPNode) -> None:
"""Remove all polygons in this BSP tree that are inside the other BSP
tree `bsp`.
"""
self.polygons = bsp.clip_polygons(self.polygons)
if self.front:
self.front.clip_to(bsp)
if self.back:
self.back.clip_to(bsp)
def all_polygons(self) -> list[Polygon]:
"""Return a list of all polygons in this BSP tree."""
polygons = self.polygons[:]
if self.front:
polygons.extend(self.front.all_polygons())
if self.back:
polygons.extend(self.back.all_polygons())
return polygons
def build(self, polygons: list[Polygon]) -> None:
"""
Build a BSP tree out of `polygons`. When called on an existing tree, the
new polygons are filtered down to the bottom of the tree and become new
nodes there. Each set of polygons is partitioned using the first polygon
(no heuristic is used to pick a good split).
"""
if len(polygons) == 0:
return
if self.plane is None:
# do a wise choice and pick the first polygon as split-plane ;)
self.plane = polygons[0].plane.clone()
# add first polygon to this node
self.polygons.append(polygons[0])
front: list[Polygon] = []
back: list[Polygon] = []
# split all other polygons at the split plane
for poly in polygons[1:]:
# coplanar front and back polygons go into self.polygons
self.plane.split_polygon(
poly, self.polygons, self.polygons, front, back
)
# recursively build the BSP tree
if len(front) > 0:
if self.front is None:
self.front = BSPNode()
self.front.build(front)
if len(back) > 0:
if self.back is None:
self.back = BSPNode()
self.back.build(back)
class CSG:
"""
Constructive Solid Geometry (CSG) is a modeling technique that uses Boolean
operations like union and intersection to combine 3D solids. This class
implements CSG operations on meshes.
New 3D solids are created from :class:`~ezdxf.render.MeshBuilder` objects
and results can be exported as :class:`~ezdxf.render.MeshTransformer` objects
to `ezdxf` by method :meth:`mesh`.
Args:
mesh: :class:`ezdxf.render.MeshBuilder` or inherited object
meshid: individual mesh ID to separate result meshes, ``0`` is default
"""
def __init__(self, mesh: Optional[MeshBuilder] = None, meshid: int = 0):
if mesh is None:
self.polygons: list[Polygon] = []
else:
mesh_copy = mesh.copy()
mesh_copy.normalize_faces()
self.polygons = [
Polygon(face, meshid) for face in mesh_copy.faces_as_vertices()
]
@classmethod
def from_polygons(cls, polygons: list[Polygon]) -> CSG:
csg = CSG()
csg.polygons = polygons
return csg
def mesh(self, meshid: int = 0) -> MeshTransformer:
"""
Returns a :class:`ezdxf.render.MeshTransformer` object.
Args:
meshid: individual mesh ID, ``0`` is default
"""
mesh = MeshVertexMerger()
for face in self.polygons:
if meshid == face.meshid:
mesh.add_face(face.vertices)
return MeshTransformer.from_builder(mesh)
def clone(self) -> CSG:
return self.from_polygons([p.clone() for p in self.polygons])
def union(self, other: CSG) -> CSG:
"""
Return a new CSG solid representing space in either this solid or in the
solid `other`. Neither this solid nor the solid `other` are modified::
A.union(B)
+-------+ +-------+
| | | |
| A | | |
| +--+----+ = | +----+
+----+--+ | +----+ |
| B | | |
| | | |
+-------+ +-------+
"""
a = BSPNode(self.clone().polygons)
b = BSPNode(other.clone().polygons)
a.clip_to(b)
b.clip_to(a)
b.invert()
b.clip_to(a)
b.invert()
a.build(b.all_polygons())
return CSG.from_polygons(a.all_polygons())
__add__ = union
def subtract(self, other: CSG) -> CSG:
"""
Return a new CSG solid representing space in this solid but not in the
solid `other`. Neither this solid nor the solid `other` are modified::
A.subtract(B)
+-------+ +-------+
| | | |
| A | | |
| +--+----+ = | +--+
+----+--+ | +----+
| B |
| |
+-------+
"""
a = BSPNode(self.clone().polygons)
b = BSPNode(other.clone().polygons)
a.invert()
a.clip_to(b)
b.clip_to(a)
b.invert()
b.clip_to(a)
b.invert()
a.build(b.all_polygons())
a.invert()
return CSG.from_polygons(a.all_polygons())
__sub__ = subtract
def intersect(self, other: CSG) -> CSG:
"""
Return a new CSG solid representing space both this solid and in the
solid `other`. Neither this solid nor the solid `other` are modified::
A.intersect(B)
+-------+
| |
| A |
| +--+----+ = +--+
+----+--+ | +--+
| B |
| |
+-------+
"""
a = BSPNode(self.clone().polygons)
b = BSPNode(other.clone().polygons)
a.invert()
b.clip_to(a)
b.invert()
a.clip_to(b)
b.clip_to(a)
a.build(b.all_polygons())
a.invert()
return CSG.from_polygons(a.all_polygons())
__mul__ = intersect
def inverse(self) -> CSG:
"""
Return a new CSG solid with solid and empty space switched. This solid is
not modified.
"""
csg = self.clone()
for p in csg.polygons:
p.flip()
return csg
@@ -0,0 +1,636 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
"""
Module to export any DXF document as DXF version R12 without modifying the source
document.
.. versionadded:: 1.1
To get the best result use the ODA File Converter add-on::
from ezdxf.addons import odafc
odafc.convert("any.dxf", "r12.dxf", version="R12")
"""
from __future__ import annotations
from typing import TYPE_CHECKING, TextIO, Callable, Optional
import os
from io import StringIO
import logging
import ezdxf
from ezdxf import const, proxygraphic, path
from ezdxf.document import Drawing
from ezdxf.entities import (
BlockRecord,
DXFEntity,
DXFTagStorage,
Ellipse,
Hatch,
Insert,
LWPolyline,
MPolygon,
MText,
Mesh,
Polyface,
Polyline,
Spline,
Textstyle,
)
from ezdxf.entities.polygon import DXFPolygon
from ezdxf.addons import MTextExplode
from ezdxf.entitydb import EntitySpace
from ezdxf.layouts import BlockLayout, VirtualLayout
from ezdxf.lldxf.tagwriter import TagWriter, AbstractTagWriter
from ezdxf.lldxf.types import DXFTag, TAG_STRING_FORMAT
from ezdxf.math import Z_AXIS, Vec3, NULLVEC
from ezdxf.r12strict import R12NameTranslator
from ezdxf.render import MeshBuilder
from ezdxf.sections.table import TextstyleTable
if TYPE_CHECKING:
from ezdxf.eztypes import GenericLayoutType
__all__ = ["R12Exporter", "convert", "saveas", "write"]
MAX_SAGITTA = 0.01
logger = logging.getLogger("ezdxf")
def convert(doc: Drawing, *, max_sagitta: float = MAX_SAGITTA) -> Drawing:
"""Export and reload DXF document as DXF version R12.
Writes the DXF document into a temporary file at the file-system and reloads this
file by the :func:`ezdxf.readfile` function.
"""
stream = StringIO()
exporter = R12Exporter(doc, max_sagitta=max_sagitta)
exporter.write(stream)
stream.seek(0)
return ezdxf.read(stream)
def write(doc: Drawing, stream: TextIO, *, max_sagitta: float = MAX_SAGITTA) -> None:
"""Write a DXF document as DXF version R12 to a text stream. The `max_sagitta`
argument determines the accuracy of the curve flatting for SPLINE and ELLIPSE
entities.
Args:
doc: DXF document to export
stream: output stream, use :attr:`doc.encoding` as encoding
max_sagitta: maximum distance from the center of the curve to the
center of the line segment between two approximation points to
determine if a segment should be subdivided.
"""
exporter = R12Exporter(doc, max_sagitta=max_sagitta)
exporter.write(stream)
def saveas(
doc: Drawing, filepath: str | os.PathLike, *, max_sagitta: float = MAX_SAGITTA
) -> None:
"""Write a DXF document as DXF version R12 to a file. The `max_sagitta`
argument determines the accuracy of the curve flatting for SPLINE and ELLIPSE
entities.
Args:
doc: DXF document to export
filepath: output filename
max_sagitta: maximum distance from the center of the curve to the
center of the line segment between two approximation points to
determine if a segment should be subdivided.
"""
with open(filepath, "wt", encoding=doc.encoding, errors="dxfreplace") as stream:
write(
doc,
stream,
max_sagitta=max_sagitta,
)
def spline_to_polyline(
spline: Spline, max_sagitta: float, min_segments: int
) -> Polyline:
polyline = Polyline.new(
dxfattribs={
"layer": spline.dxf.layer,
"linetype": spline.dxf.linetype,
"color": spline.dxf.color,
"flags": const.POLYLINE_3D_POLYLINE,
}
)
polyline.append_vertices(points=spline.flattening(max_sagitta, min_segments))
polyline.new_seqend()
return polyline
def ellipse_to_polyline(
ellipse: Ellipse, max_sagitta: float, min_segments: int
) -> Polyline:
polyline = Polyline.new(
dxfattribs={
"layer": ellipse.dxf.layer,
"linetype": ellipse.dxf.linetype,
"color": ellipse.dxf.color,
"flags": const.POLYLINE_3D_POLYLINE,
}
)
polyline.append_vertices(points=ellipse.flattening(max_sagitta, min_segments))
polyline.new_seqend()
return polyline
def lwpolyline_to_polyline(lwpolyline: LWPolyline) -> Polyline:
polyline = Polyline.new(
dxfattribs={
"layer": lwpolyline.dxf.layer,
"linetype": lwpolyline.dxf.linetype,
"color": lwpolyline.dxf.color,
}
)
polyline.new_seqend()
polyline.append_formatted_vertices(lwpolyline.get_points(), format="xyseb")
if lwpolyline.is_closed:
polyline.close()
if lwpolyline.dxf.hasattr("const_width"):
width = lwpolyline.dxf.const_width
polyline.dxf.default_start_width = width
polyline.dxf.default_end_width = width
extrusion = Vec3(lwpolyline.dxf.extrusion)
if not extrusion.isclose(Z_AXIS):
polyline.dxf.extrusion = extrusion
elevation = lwpolyline.dxf.elevation
polyline.dxf.elevation = Vec3(0, 0, elevation)
# Set z-axis of VERTEX.location to elevation?
return polyline
def mesh_to_polyface_mesh(mesh: Mesh) -> Polyface:
builder = MeshBuilder.from_mesh(mesh)
return builder.render_polyface(
VirtualLayout(),
dxfattribs={
"layer": mesh.dxf.layer,
"linetype": mesh.dxf.linetype,
"color": mesh.dxf.color,
},
)
def get_xpl_block_name(entity: DXFEntity) -> str:
assert entity.dxf.handle is not None
return f"EZDXF_XPL_{entity.dxftype()}_{entity.dxf.handle}"
def export_lwpolyline(exporter: R12Exporter, entity: DXFEntity):
assert isinstance(entity, LWPolyline)
polyline = lwpolyline_to_polyline(entity)
if len(polyline.vertices):
polyline.export_dxf(exporter.tagwriter())
def export_mesh(exporter: R12Exporter, entity: DXFEntity):
assert isinstance(entity, Mesh)
polyface_mesh = mesh_to_polyface_mesh(entity)
if len(polyface_mesh.vertices):
polyface_mesh.export_dxf(exporter.tagwriter())
def export_spline(exporter: R12Exporter, entity: DXFEntity):
assert isinstance(entity, Spline)
polyline = spline_to_polyline(
entity, exporter.max_sagitta, exporter.min_spline_segments
)
if len(polyline.vertices):
polyline.export_dxf(exporter.tagwriter())
def export_ellipse(exporter: R12Exporter, entity: DXFEntity):
assert isinstance(entity, Ellipse)
polyline = ellipse_to_polyline(
entity, exporter.max_sagitta, exporter.min_ellipse_segments
)
if len(polyline.vertices):
polyline.export_dxf(exporter.tagwriter())
def make_insert(name: str, entity: DXFEntity, location=NULLVEC) -> Insert:
return Insert.new(
dxfattribs={
"name": name,
"layer": entity.dxf.layer,
"linetype": entity.dxf.linetype,
"color": entity.dxf.color,
"insert": location,
}
)
def export_proxy_graphic(exporter: R12Exporter, entity: DXFEntity):
assert isinstance(entity.proxy_graphic, bytes)
pg = proxygraphic.ProxyGraphic(entity.proxy_graphic)
try:
entities = EntitySpace(pg.virtual_entities())
except proxygraphic.ProxyGraphicError:
return
exporter.export_entity_space(entities)
def export_mtext(exporter: R12Exporter, entity: DXFEntity):
assert isinstance(entity, MText)
layout = VirtualLayout()
exporter.explode_mtext(entity, layout)
exporter.export_entity_space(layout.entity_space)
def export_virtual_entities(exporter: R12Exporter, entity: DXFEntity):
layout = VirtualLayout()
try:
for e in entity.virtual_entities(): # type: ignore
layout.add_entity(e)
except Exception:
return
exporter.export_entity_space(layout.entity_space)
def export_pattern_fill(entity: DXFEntity, block: BlockLayout) -> None:
assert isinstance(entity, DXFPolygon)
dxfattribs = {
"layer": entity.dxf.layer,
"color": entity.dxf.color,
}
for start, end in entity.render_pattern_lines():
block.add_line(start, end, dxfattribs=dxfattribs)
def export_solid_fill(
entity: DXFPolygon,
block: BlockLayout,
max_sagitta: float,
min_segments: int,
) -> None:
dxfattribs = {
"layer": entity.dxf.layer,
"color": entity.dxf.color,
}
extrusion = Vec3(entity.dxf.extrusion)
if not extrusion.is_null and not extrusion.isclose(Z_AXIS):
dxfattribs["extrusion"] = extrusion
# triangulation in OCS coordinates, including elevation and offset values:
for vertices in entity.triangulate(max_sagitta, min_segments):
block.add_solid(vertices, dxfattribs=dxfattribs)
def export_hatch(exporter: R12Exporter, entity: DXFEntity) -> None:
assert isinstance(entity, Hatch)
# export hatch into an anonymous block
block = exporter.new_block(entity)
insert = make_insert(block.name, entity)
insert.export_dxf(exporter.tagwriter())
if entity.has_pattern_fill:
export_pattern_fill(entity, block)
else:
export_solid_fill(
entity, block, exporter.max_sagitta, exporter.min_spline_segments
)
def export_mpolygon(exporter: R12Exporter, entity: DXFEntity) -> None:
assert isinstance(entity, MPolygon)
# export mpolygon into an anonymous block
block = exporter.new_block(entity)
insert = make_insert(block.name, entity)
insert.export_dxf(exporter.tagwriter())
# elevation is the z-axis of the vertices
path.render_polylines2d(
block,
path.from_hatch_ocs(entity, offset=Vec3(entity.dxf.offset)),
distance=exporter.max_sagitta,
segments=exporter.min_spline_segments,
extrusion=Vec3(entity.dxf.extrusion),
dxfattribs={
"layer": entity.dxf.layer,
"linetype": entity.dxf.linetype,
"color": entity.dxf.color,
},
)
if entity.has_pattern_fill:
export_pattern_fill(entity, block)
else:
export_solid_fill(
entity, block, exporter.max_sagitta, exporter.min_spline_segments
)
def export_acad_table(exporter: R12Exporter, entity: DXFEntity) -> None:
from ezdxf.entities.acad_table import AcadTableBlockContent
assert isinstance(entity, AcadTableBlockContent)
table: AcadTableBlockContent = entity
location = table.get_insert_location()
block_name = table.get_block_name()
if not block_name.startswith("*T"):
return
try:
acdb_entity = table.xtags.get_subclass("AcDbEntity")
except const.DXFIndexError:
return
layer = acdb_entity.get_first_value(8, "0")
insert = Insert.new(
dxfattribs={"name": block_name, "layer": layer, "insert": location}
)
insert.export_dxf(exporter.tagwriter())
# Planned features: explode complex newer entity types into DXF primitives.
# currently skipped entity types:
# - ACAD_TABLE: graphic as geometry block is available
# --------------------------------------------------------------------------------------
# - all ACIS based entities: tessellated meshes could be exported, but very much work
# and beyond my current knowledge
# - IMAGE and UNDERLAY: no support possible
# - XRAY and XLINE: no support possible (infinite lines)
# Possible name tags to translate:
# 1 The primary text value for an entity - never a name
# 2 A name: Attribute tag, Block name, and so on. Also used to identify a DXF section or
# table name
# 3 Other textual or name values - only in DIMENSION a name
# 4 Other textual values - never a name!
# 5 Entity handle expressed as a hexadecimal string (fixed)
# 6 Line type name (fixed)
# 7 Text style name (fixed)
# 8 Layer name (fixed)
# 1001: AppID
# 1003: layer name in XDATA (fixed)
NAME_TAG_CODES = {2, 3, 6, 7, 8, 1001, 1003}
class R12TagWriter(TagWriter):
def __init__(self, stream: TextIO):
super().__init__(stream, dxfversion=const.DXF12, write_handles=False)
self.skip_xdata = False
self.current_entity = ""
self.translator = R12NameTranslator()
def set_stream(self, stream: TextIO) -> None:
self._stream = stream
def write_tag(self, tag: DXFTag) -> None:
code, value = tag
if code == 0:
self.current_entity = str(value)
if code > 999 and self.skip_xdata:
return
if code in NAME_TAG_CODES:
self._stream.write(
TAG_STRING_FORMAT % (code, self.sanitize_name(code, value))
)
else:
self._stream.write(tag.dxfstr())
def write_tag2(self, code: int, value) -> None:
if code > 999 and self.skip_xdata:
return
if code == 0:
self.current_entity = str(value)
if code in NAME_TAG_CODES:
value = self.sanitize_name(code, value)
self._stream.write(TAG_STRING_FORMAT % (code, value))
def sanitize_name(self, code: int, name: str) -> str:
# sanitize group code 3 + 4
# LTYPE - <description> has group code - not a table name
# STYLE - <font> has group code (3) - not a table name
# STYLE - <bigfont> has group code (4) - not a table name
# DIMSTYLE - <dimpost> has group code e.g. "<> mm" (3) - not a table name
# DIMSTYLE - <dimapost> has group code (4) - not a table name
# ATTDEF - <prompt> has group code (3) - not a table name
# DIMENSION - <dimstyle> has group code (3) - is a table name!
if code == 3 and self.current_entity != "DIMENSION":
return name
return self.translator.translate(name)
class SpecialStyleTable:
def __init__(self, styles: TextstyleTable, extra_styles: TextstyleTable):
self.styles = styles
self.extra_styles = extra_styles
def get_text_styles(self) -> list[Textstyle]:
entries = list(self.styles.entries.values())
for name, extra_style in self.extra_styles.entries.items():
if not self.styles.has_entry(name):
entries.append(extra_style)
return entries
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
text_styles = self.get_text_styles()
tagwriter.write_tag2(0, "TABLE")
tagwriter.write_tag2(2, "STYLE")
tagwriter.write_tag2(70, len(text_styles))
for style in text_styles:
style.export_dxf(tagwriter)
tagwriter.write_tag2(0, "ENDTAB")
EOF_STR = "0\nEOF\n"
def detect_max_block_number(names: list[str]) -> int:
max_number = 0
for name in names:
name = name.upper()
if not name.startswith("*"):
continue
try: # *U10
number = int(name[2:])
except ValueError:
continue
max_number = max(max_number, number)
return max_number + 1
class R12Exporter:
def __init__(self, doc: Drawing, max_sagitta: float = 0.01):
assert isinstance(doc, Drawing)
self._doc = doc
self._tagwriter = R12TagWriter(StringIO())
self.max_sagitta = float(max_sagitta) # flattening SPLINE, ELLIPSE
self.min_spline_segments: int = 4 # flattening SPLINE
self.min_ellipse_segments: int = 8 # flattening ELLIPSE
self._extra_doc = ezdxf.new("R12")
self._next_block_number = detect_max_block_number(
[br.dxf.name for br in doc.block_records]
)
# Exporters are required to convert newer entity types into DXF R12 types.
# All newer entity types without an exporter will be ignored.
self.exporters: dict[str, Callable[[R12Exporter, DXFEntity], None]] = {
"LWPOLYLINE": export_lwpolyline,
"MESH": export_mesh,
"SPLINE": export_spline,
"ELLIPSE": export_ellipse,
"MTEXT": export_mtext,
"LEADER": export_virtual_entities,
"MLEADER": export_virtual_entities,
"MULTILEADER": export_virtual_entities,
"MLINE": export_virtual_entities,
"HATCH": export_hatch,
"MPOLYGON": export_mpolygon,
"ACAD_TABLE": export_acad_table,
}
def disable_exporter(self, entity_type: str):
del self.exporters[entity_type]
@property
def doc(self) -> Drawing:
return self._doc
def tagwriter(self, stream: Optional[TextIO] = None) -> R12TagWriter:
if stream is not None:
self._tagwriter.set_stream(stream)
return self._tagwriter
def write(self, stream: TextIO) -> None:
"""Write DXF document to text stream."""
stream.write(self.to_string())
def to_string(self) -> str:
"""Export DXF document as string."""
# export layouts before blocks: may create new anonymous blocks
entities = self.export_layouts_to_string()
# export blocks before HEADER and TABLES sections: may create new text styles
blocks = self.export_blocks_to_string()
return "".join(
(
self.export_header_to_string(),
self.export_tables_to_string(),
blocks,
entities,
EOF_STR,
)
)
def next_block_name(self, char: str) -> str:
name = f"*{char}{self._next_block_number}"
self._next_block_number += 1
return name
def new_block(self, entity: DXFEntity) -> BlockLayout:
name = self.next_block_name("U")
return self._extra_doc.blocks.new(
name,
dxfattribs={
"layer": entity.dxf.get("layer", "0"),
"flags": const.BLK_ANONYMOUS,
},
)
def export_header_to_string(self) -> str:
in_memory_stream = StringIO()
self.doc.header.export_dxf(self.tagwriter(in_memory_stream))
return in_memory_stream.getvalue()
def export_tables_to_string(self) -> str:
# DXF R12 does not support XDATA in tables according Autodesk DWG TrueView
in_memory_stream = StringIO()
tables = self.doc.tables
preserve_table = tables.styles
tables.styles = SpecialStyleTable(self.doc.styles, self._extra_doc.styles) # type: ignore
tagwriter = self.tagwriter(in_memory_stream)
tagwriter.skip_xdata = True
tables.export_dxf(tagwriter)
tables.styles = preserve_table
tagwriter.skip_xdata = False
return in_memory_stream.getvalue()
def export_blocks_to_string(self) -> str:
in_memory_stream = StringIO()
self._tagwriter.set_stream(in_memory_stream)
self._write_section_header("BLOCKS")
for block_record in self.doc.block_records:
if block_record.is_any_paperspace and not block_record.is_active_paperspace:
continue
name = block_record.dxf.name.lower()
if name in ("$model_space", "$paper_space"):
# These block names collide with the translated names of the *Model_Space
# and the *Paper_Space blocks.
continue
self._export_block_record(block_record)
extra_blocks = self.get_extra_blocks()
while len(extra_blocks):
for block_record in extra_blocks:
self._export_block_record(block_record)
self.discard_extra_block(block_record.dxf.name)
# block record export can create further blocks
extra_blocks = self.get_extra_blocks()
self._write_endsec()
return in_memory_stream.getvalue()
def discard_extra_block(self, name: str) -> None:
self._extra_doc.block_records.discard(name)
def get_extra_blocks(self) -> list[BlockRecord]:
return [
br for br in self._extra_doc.block_records if br.dxf.name.startswith("*U")
]
def explode_mtext(self, mtext: MText, layout: GenericLayoutType):
with MTextExplode(layout, self._extra_doc) as xpl:
xpl.explode(mtext, destroy=False)
def export_layouts_to_string(self) -> str:
in_memory_stream = StringIO()
self._tagwriter.set_stream(in_memory_stream)
self._write_section_header("ENTITIES")
self.export_entity_space(self.doc.modelspace().entity_space)
self.export_entity_space(self.doc.paperspace().entity_space)
self._write_endsec()
return in_memory_stream.getvalue()
def _export_block_record(self, block_record: BlockRecord):
tagwriter = self._tagwriter
assert block_record.block is not None
block_record.block.export_dxf(tagwriter)
if not block_record.is_any_layout:
self.export_entity_space(block_record.entity_space)
assert block_record.endblk is not None
block_record.endblk.export_dxf(tagwriter)
def export_entity_space(self, space: EntitySpace):
tagwriter = self._tagwriter
for entity in space:
if entity.MIN_DXF_VERSION_FOR_EXPORT > const.DXF12 or isinstance(
entity, DXFTagStorage
):
exporter = self.exporters.get(entity.dxftype())
if exporter:
exporter(self, entity)
continue
if entity.proxy_graphic:
export_proxy_graphic(self, entity)
else:
entity.export_dxf(tagwriter)
def _write_section_header(self, name: str) -> None:
self._tagwriter.write_str(f" 0\nSECTION\n 2\n{name}\n")
def _write_endsec(self) -> None:
self._tagwriter.write_tag2(0, "ENDSEC")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,235 @@
# Purpose: create sierpinski pyramid geometry
# Copyright (c) 2016-2022 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Sequence, Iterator, Optional
import math
from ezdxf.math import Vec3, UVec, Matrix44, UCS
from ezdxf.render.mesh import MeshVertexMerger, MeshTransformer
if TYPE_CHECKING:
from ezdxf.eztypes import GenericLayoutType
HEIGHT4 = 1.0 / math.sqrt(2.0) # pyramid4 height (* length)
HEIGHT3 = math.sqrt(6.0) / 3.0 # pyramid3 height (* length)
DY1_FACTOR = math.tan(math.pi / 6.0) / 2.0 # inner circle radius
DY2_FACTOR = 0.5 / math.cos(math.pi / 6.0) # outer circle radius
class SierpinskyPyramid:
"""
Args:
location: location of base center as (x, y, z) tuple
length: side length
level: subdivide level
sides: sides of base geometry
"""
def __init__(
self,
location: UVec = (0.0, 0.0, 0.0),
length: float = 1.0,
level: int = 1,
sides: int = 4,
):
self.sides = sides
self.pyramid_definitions = sierpinsky_pyramid(
location=location, length=length, level=level, sides=sides
)
def vertices(self) -> Iterator[list[Vec3]]:
"""Yields the pyramid vertices as list of :class:`ezdxf.math.Vec3`."""
for location, length in self.pyramid_definitions:
yield self._calc_vertices(location, length)
__iter__ = vertices
def _calc_vertices(
self, location: UVec, length: float
) -> list[Vec3]:
"""
Calculates the pyramid vertices.
Args:
location: location of the pyramid as center point of the base
length: pyramid side length
Returns: list of :class:`ezdxf.math.Vec3`
"""
len2 = length / 2.0
x, y, z = location
if self.sides == 4:
return [
Vec3(x - len2, y - len2, z),
Vec3(x + len2, y - len2, z),
Vec3(x + len2, y + len2, z),
Vec3(x - len2, y + len2, z),
Vec3(x, y, z + length * HEIGHT4),
]
elif self.sides == 3:
dy1 = length * DY1_FACTOR
dy2 = length * DY2_FACTOR
return [
Vec3(x - len2, y - dy1, z),
Vec3(x + len2, y - dy1, z),
Vec3(x, y + dy2, z),
Vec3(x, y, z + length * HEIGHT3),
]
else:
raise ValueError("sides has to be 3 or 4.")
def faces(self) -> list[Sequence[int]]:
"""Returns list of pyramid faces. All pyramid vertices have the same
order, so one faces list fits them all.
"""
if self.sides == 4:
return [(3, 2, 1, 0), (0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4)]
elif self.sides == 3:
return [(2, 1, 0), (0, 1, 3), (1, 2, 3), (2, 0, 3)]
else:
raise ValueError("sides has to be 3 or 4.")
def render(
self,
layout: GenericLayoutType,
merge: bool = False,
dxfattribs=None,
matrix: Optional[Matrix44] = None,
ucs: Optional[UCS] = None,
) -> None:
"""Renders the sierpinsky pyramid into layout, set `merge` to ``True``
for rendering the whole sierpinsky pyramid into one MESH entity, set
`merge` to ``False`` for individual pyramids as MESH entities.
Args:
layout: DXF target layout
merge: ``True`` for one MESH entity, ``False`` for individual MESH
entities per pyramid
dxfattribs: DXF attributes for the MESH entities
matrix: apply transformation matrix at rendering
ucs: apply UCS at rendering
"""
if merge:
mesh = self.mesh()
mesh.render_mesh(
layout, dxfattribs=dxfattribs, matrix=matrix, ucs=ucs
)
else:
for pyramid in self.pyramids():
pyramid.render_mesh(layout, dxfattribs, matrix=matrix, ucs=ucs)
def pyramids(self) -> Iterable[MeshTransformer]:
"""Yields all pyramids of the sierpinsky pyramid as individual
:class:`MeshTransformer` objects.
"""
faces = self.faces()
for vertices in self:
mesh = MeshTransformer()
mesh.add_mesh(vertices=vertices, faces=faces)
yield mesh
def mesh(self) -> MeshTransformer:
"""Returns geometry as one :class:`MeshTransformer` object."""
faces = self.faces()
mesh = MeshVertexMerger()
for vertices in self:
mesh.add_mesh(vertices=vertices, faces=faces)
return MeshTransformer.from_builder(mesh)
def sierpinsky_pyramid(
location=(0.0, 0.0, 0.0),
length: float = 1.0,
level: int = 1,
sides: int = 4,
) -> list[tuple[Vec3, float]]:
"""Build a Sierpinski pyramid.
Args:
location: base center point of the pyramid
length: base length of the pyramid
level: recursive building levels, has to 1 or bigger
sides: 3 or 4 sided pyramids supported
Returns: list of pyramid vertices
"""
location = Vec3(location)
level = int(level)
if level < 1:
raise ValueError("level has to be 1 or bigger.")
pyramids = _sierpinsky_pyramid(location, length, sides)
for _ in range(level - 1):
next_level_pyramids = []
for location, length in pyramids:
next_level_pyramids.extend(
_sierpinsky_pyramid(location, length, sides)
)
pyramids = next_level_pyramids
return pyramids
def _sierpinsky_pyramid(
location: Vec3, length: float = 1.0, sides: int = 4
) -> list[tuple[Vec3, float]]:
if sides == 3:
return sierpinsky_pyramid_3(location, length)
elif sides == 4:
return sierpinsky_pyramid_4(location, length)
else:
raise ValueError("sides has to be 3 or 4.")
def sierpinsky_pyramid_4(
location: Vec3, length: float = 1.0
) -> list[tuple[Vec3, float]]:
"""Build a 4-sided Sierpinski pyramid. Pyramid height = length of the base
square!
Args:
location: base center point of the pyramid
length: base length of the pyramid
Returns: list of (location, length) tuples, representing the sierpinski pyramid
"""
len2 = length / 2
len4 = length / 4
x, y, z = location
return [
(Vec3(x - len4, y - len4, z), len2),
(Vec3(x + len4, y - len4, z), len2),
(Vec3(x - len4, y + len4, z), len2),
(Vec3(x + len4, y + len4, z), len2),
(Vec3(x, y, z + len2 * HEIGHT4), len2),
]
def sierpinsky_pyramid_3(
location: Vec3, length: float = 1.0
) -> list[tuple[Vec3, float]]:
"""Build a 3-sided Sierpinski pyramid (tetraeder).
Args:
location: base center point of the pyramid
length: base length of the pyramid
Returns: list of (location, length) tuples, representing the sierpinski pyramid
"""
dy1 = length * DY1_FACTOR * 0.5
dy2 = length * DY2_FACTOR * 0.5
len2 = length / 2
len4 = length / 4
x, y, z = location
return [
(Vec3(x - len4, y - dy1, z), len2),
(Vec3(x + len4, y - dy1, z), len2),
(Vec3(x, y + dy2, z), len2),
(Vec3(x, y, z + len2 * HEIGHT3), len2),
]
@@ -0,0 +1,927 @@
# Copyright (c) 2010-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Any,
Callable,
Iterable,
Iterator,
Optional,
Sequence,
TYPE_CHECKING,
TypeVar,
)
from copy import deepcopy
from ezdxf.lldxf import const
from ezdxf.enums import (
MTextEntityAlignment,
MAP_MTEXT_ALIGN_TO_FLAGS,
)
from ezdxf.addons import MTextSurrogate
from ezdxf.math import UVec, Vec2
if TYPE_CHECKING:
from ezdxf.layouts import BlockLayout
from ezdxf.eztypes import GenericLayoutType
DEFAULT_TABLE_BG_LAYER = "TABLEBACKGROUND"
DEFAULT_TABLE_FG_LAYER = "TABLECONTENT"
DEFAULT_TABLE_GRID_LAYER = "TABLEGRID"
DEFAULT_TEXT_STYLE = "STANDARD"
DEFAULT_CELL_HEIGHT = 1.0
DEFAULT_CELL_WIDTH = 2.5
DEFAULT_CELL_CHAR_HEIGHT = 0.7
DEFAULT_CELL_LINE_SPACING = 1.5
DEFAULT_CELL_X_SCALE = 1.0
DEFAULT_CELL_Y_SCALE = 1.0
DEFAULT_CELL_TEXTCOLOR = const.BYLAYER
DEFAULT_CELL_BG_COLOR = None
DEFAULT_CELL_X_MARGIN = 0.1
DEFAULT_CELL_Y_MARGIN = 0.1
DEFAULT_BORDER_COLOR = 5
DEFAULT_BORDER_LINETYPE = "BYLAYER"
DEFAULT_BORDER_STATUS = True
DEFAULT_BORDER_PRIORITY = 50
T = TypeVar("T", bound="Cell")
class TablePainter:
"""The TablePainter class renders tables build from DXF primitives.
The TablePainter instance contains all the data cells.
Args:
insert: insert location as or :class:`~ezdxf.math.UVec`
nrows: row count
ncols: column count
cell_width: default cell width in drawing units
cell_height: default cell height in drawing units
default_grid: draw a grid of solid lines if ``True``, otherwise
draw only explicit defined borders, the default grid has a
priority of 50.
"""
def __init__(
self,
insert: UVec,
nrows: int,
ncols: int,
cell_width=DEFAULT_CELL_WIDTH,
cell_height=DEFAULT_CELL_HEIGHT,
default_grid=True,
):
self.insert = Vec2(insert)
self.nrows: int = nrows
self.ncols: int = ncols
self.row_heights: list[float] = [cell_height] * nrows
self.col_widths: list[float] = [cell_width] * ncols
self.bg_layer_name: str = DEFAULT_TABLE_BG_LAYER
self.fg_layer_name: str = DEFAULT_TABLE_FG_LAYER
self.grid_layer_name: str = DEFAULT_TABLE_GRID_LAYER
self.styles: dict[str, CellStyle] = {"default": CellStyle()}
if not default_grid:
default_style = self.get_cell_style("default")
default_style.set_border_status(False, False, False, False)
self._cells: dict[tuple[int, int], Cell] = {} # data cells
self.frames: list[Frame] = [] # border frame objects
self.empty_cell = Cell(self) # represents all empty cells
def set_col_width(self, index: int, value: float):
"""Set column width in drawing units of the given column index.
Args:
index: zero based column index
value: new column width in drawing units
"""
self.col_widths[index] = float(value)
def set_row_height(self, index: int, value: float):
"""Set row height in drawing units of the given row index.
Args:
index: zero based row index
value: new row height in drawing units
"""
self.row_heights[index] = float(value)
def text_cell(
self,
row: int,
col: int,
text: str,
span: tuple[int, int] = (1, 1),
style="default",
) -> TextCell:
"""Factory method to create a new text cell at location (row, col),
with `text` as content, the `text` can be a line breaks ``'\\n'``.
The final cell can spread over several cells defined by the argument
`span`.
"""
cell = TextCell(self, text, style=style, span=span)
return self.set_cell(row, col, cell)
def block_cell(
self,
row: int,
col: int,
blockdef: BlockLayout,
span: tuple[int, int] = (1, 1),
attribs=None,
style="default",
) -> BlockCell:
"""Factory method to Create a new block cell at position (row, col).
Content is a block reference inserted by an INSERT entity,
attributes will be added if the block definition contains ATTDEF.
Assignments are defined by attribs-key to attdef-tag association.
Example: attribs = {'num': 1} if an ATTDEF with tag=='num' in
the block definition exists, an attrib with text=str(1) will be
created and added to the insert entity.
The cell spans over 'span' cells and has the cell style with the
name 'style'.
"""
if attribs is None:
attribs = {}
cell = BlockCell(
self, blockdef, style=style, attribs=attribs, span=span
)
return self.set_cell(row, col, cell)
@property
def table_width(self) -> float:
"""Returns the total table width."""
return sum(self.col_widths)
@property
def table_height(self) -> float:
"""Returns the total table height."""
return sum(self.row_heights)
def set_cell(self, row: int, col: int, cell: T) -> T:
"""Insert a cell at position (row, col)."""
row, col = self.validate_index(row, col)
self._cells[row, col] = cell
return cell
def get_cell(self, row: int, col: int) -> Cell:
"""Get cell at location (row, col)."""
row, col = self.validate_index(row, col)
try:
return self._cells[row, col]
except KeyError:
return self.empty_cell # empty cell with default style
def validate_index(self, row: int, col: int) -> tuple[int, int]:
row = int(row)
col = int(col)
if row < 0 or row >= self.nrows or col < 0 or col >= self.ncols:
raise IndexError("cell index out of range")
return row, col
def frame(
self,
row: int,
col: int,
width: int = 1,
height: int = 1,
style="default",
) -> Frame:
"""Creates a frame around the give cell area, starting at (row, col) and
covering `width` columns and `height` rows. The `style` argument is the
name of a :class:`CellStyle`.
"""
frame = Frame(self, pos=(row, col), span=(height, width), style=style)
self.frames.append(frame)
return frame
def new_cell_style(self, name: str, **kwargs) -> CellStyle:
"""Factory method to create a new :class:`CellStyle` object, overwrites
an already existing cell style.
Args:
name: style name as string
kwargs: see attributes of class :class:`CellStyle`
"""
assert (
isinstance(name, str) and name != ""
), "name has to be a non-empty string"
style: CellStyle = deepcopy(self.get_cell_style("default"))
style.update(kwargs)
self.styles[name] = style
return style
@staticmethod
def new_border_style(
color: int = const.BYLAYER,
status=True,
priority: int = 100,
linetype: str = "BYLAYER",
lineweight: int = const.LINEWEIGHT_BYLAYER,
) -> BorderStyle:
"""Factory method to create a new border style.
Args:
status: ``True`` for visible, ``False`` for invisible
color: :ref:`ACI`
linetype: linetype name, default is "BYLAYER"
lineweight: lineweight as int, default is by layer
priority: drawing priority, higher priorities cover lower priorities
"""
border_style = BorderStyle()
border_style.color = color
border_style.linetype = linetype
border_style.lineweight = lineweight
border_style.status = status
border_style.priority = priority
return border_style
def get_cell_style(self, name: str) -> CellStyle:
"""Get cell style by name."""
return self.styles[name]
def iter_visible_cells(
self, visibility_map: VisibilityMap
) -> Iterator[tuple[int, int, Cell]]:
"""Iterate over all visible cells"""
return (
(row, col, self.get_cell(row, col)) for row, col in visibility_map
)
def render(self, layout: GenericLayoutType, insert: Optional[UVec] = None):
"""Render table to layout."""
insert_backup = self.insert
if insert is not None:
self.insert = Vec2(insert)
visibility_map = VisibilityMap(self)
grid = Grid(self)
for row, col, cell in self.iter_visible_cells(visibility_map):
grid.render_cell_background(layout, row, col, cell)
grid.render_cell_content(layout, row, col, cell)
grid.render_lines(layout, visibility_map)
self.insert = insert_backup
class VisibilityMap:
"""Stores the visibility of the table cells."""
def __init__(self, table: TablePainter):
"""Create the visibility map for table."""
self.table: TablePainter = table
self._hidden_cells: set[tuple[int, int]] = set()
self._create_visibility_map()
def _create_visibility_map(self):
"""Set visibility for all existing cells."""
for row, col in iter(self):
cell = self.table.get_cell(row, col)
self._set_span_visibility(row, col, cell.span)
def _set_span_visibility(self, row: int, col: int, span: tuple[int, int]):
"""Set the visibility of the given cell.
The cell itself is visible, all other cells in the span-range
(tuple: width, height) are invisible, they are covered by the
main cell (row, col).
"""
if span != (1, 1):
nrows, ncols = span
for rowx in range(nrows):
for colx in range(ncols):
# switch all cells in span range to invisible
self.hide(row + rowx, col + colx)
# switch content cell visible
self.show(row, col)
def show(self, row: int, col: int):
"""Show cell (row, col)."""
try:
self._hidden_cells.remove((row, col))
except KeyError:
pass
def hide(self, row: int, col: int) -> None:
"""Hide cell (row, col)."""
self._hidden_cells.add((row, col))
def iter_all_cells(self) -> Iterator[tuple[int, int]]:
"""Iterate over all cell indices, yields (row, col) tuples."""
for row in range(self.table.nrows):
for col in range(self.table.ncols):
yield row, col
def is_visible_cell(self, row: int, col: int) -> bool:
"""True if cell (row, col) is visible, else False."""
return (row, col) not in self._hidden_cells
def __iter__(self) -> Iterator[tuple[int, int]]:
"""Iterate over all visible cells."""
return (
(row, col)
for (row, col) in self.iter_all_cells()
if self.is_visible_cell(row, col)
)
class CellStyle:
"""Cell style object.
.. important::
Always instantiate new styles by the factory method:
:meth:`TablePainter.new_cell_style`
"""
def __init__(self, data: Optional[dict[str, Any]] = None):
# text style is ignored by block cells
self.text_style = "STANDARD"
# text height in drawing units, ignored by block cells
self.char_height = DEFAULT_CELL_CHAR_HEIGHT
# line spacing in percent = char_height * line_spacing, ignored by block cells
self.line_spacing = DEFAULT_CELL_LINE_SPACING
# text stretching factor (width factor) or block reference x-scaling factor
self.scale_x = DEFAULT_CELL_X_SCALE
# block reference y-axis scaling factor, ignored by text cells
self.scale_y = DEFAULT_CELL_Y_SCALE
# dxf color index, ignored by block cells
self.text_color = DEFAULT_CELL_TEXTCOLOR
# text or block rotation in degrees
self.rotation = 0.0
# Letters are stacked top-to-bottom, but not rotated
self.stacked = False
# text and block alignment, see ezdxf.enums.MTextEntityAlignment
self.align = MTextEntityAlignment.TOP_CENTER
# left and right cell margin in drawing units
self.margin_x = DEFAULT_CELL_X_MARGIN
# top and bottom cell margin in drawing units
self.margin_y = DEFAULT_CELL_Y_MARGIN
# background color, dxf color index, ignored by block cells
self.bg_color = DEFAULT_CELL_BG_COLOR
# left border style
self.left = BorderStyle()
# top border style
self.top = BorderStyle()
# right border style
self.right = BorderStyle()
# bottom border style
self.bottom = BorderStyle()
if data:
self.update(data)
def __getitem__(self, k: str) -> Any:
return self.__dict__[k]
def __setitem__(self, k: str, v: Any):
if k in self.__dict__:
self.__dict__.__setitem__(k, v)
else:
raise KeyError(f"invalid attribute name: {k}")
def update(self, data: dict[str, Any]):
for k, v in data.items():
self.__setitem__(k, v)
assert isinstance(
self.align, MTextEntityAlignment
), "enum ezdxf.enums.MTextEntityAlignment for text alignments required"
def set_border_status(self, left=True, right=True, top=True, bottom=True):
"""Set status of all cell borders at once."""
self.left.status = left
self.right.status = right
self.top.status = top
self.bottom.status = bottom
def set_border_style(
self, style: BorderStyle, left=True, right=True, top=True, bottom=True
):
"""Set border styles of all cell borders at once."""
for border, status in (
("left", left),
("right", right),
("top", top),
("bottom", bottom),
):
if status:
self[border] = style
@staticmethod
def get_default_border_style() -> BorderStyle:
return BorderStyle()
def get_text_align_flags(self) -> tuple[int, int]:
return MAP_MTEXT_ALIGN_TO_FLAGS[self.align]
class BorderStyle:
"""Border style class.
.. important::
Always instantiate new border styles by the factory method:
:meth:`TablePainter.new_border_style`
"""
def __init__(
self,
status: bool = DEFAULT_BORDER_STATUS,
color: int = DEFAULT_BORDER_COLOR,
linetype: str = DEFAULT_BORDER_LINETYPE,
lineweight=const.LINEWEIGHT_BYLAYER,
priority: int = DEFAULT_BORDER_PRIORITY,
):
# border status, True for visible, False for hidden
self.status = status
# ACI
self.color = color
# linetype name, BYLAYER if None
self.linetype = linetype
# lineweight
self.lineweight = lineweight
# drawing priority, higher values cover lower values
self.priority = priority
class Grid:
"""Grid contains the graphical representation of the table."""
def __init__(self, table: TablePainter):
self.table: TablePainter = table
# contains the x-axis coords of the grid lines between the data columns.
self.col_pos: list[float] = self._calc_col_pos()
# contains the y-axis coords of the grid lines between the data rows.
self.row_pos: list[float] = self._calc_row_pos()
# _x_borders contains the horizontal border elements, list of border styles
# get index with _border_index(row, col), which means the border element
# above row, col, and row-indices are [0 .. nrows+1], nrows+1 for the
# grid line below the last row; list contains only the border style with
# the highest priority.
self._x_borders: list[BorderStyle] = [] # created in _init_borders
# _y_borders: same as _x_borders but for the vertical borders,
# col-indices are [0 .. ncols+1], ncols+1 for the last grid line right
# of the last column
self._y_borders: list[BorderStyle] = [] # created in _init_borders
# border style to delete borders inside of merged cells
self.no_border = BorderStyle(
status=False, priority=999, linetype="BYLAYER", color=0
)
def _init_borders(self, x_border: BorderStyle, y_border: BorderStyle):
"""Init the _hborders with <hborder> and _vborders with <vborder>."""
# <border_count> has more elements than necessary, but it unifies the
# index calculation for _vborders and _hborders.
# exact values are:
# x_border_count = ncols * (nrows+1), hindex = ncols * <row> + <col>
# y_border_count = nrows * (ncols+1), vindex = (ncols+1) * <row> + <col>
border_count: int = (self.table.nrows + 1) * (self.table.ncols + 1)
self._x_borders = [x_border] * border_count
self._y_borders = [y_border] * border_count
def _border_index(self, row: int, col: int) -> int:
"""Calculate linear index for border arrays _x_borders and _y_borders."""
return row * (self.table.ncols + 1) + col
def set_x_border(self, row: int, col: int, border_style: BorderStyle):
"""Set <border_style> for the horizontal border element above
<row>, <col>.
"""
return self._set_border_style(self._x_borders, row, col, border_style)
def set_y_border(self, row: int, col: int, border_style: BorderStyle):
"""Set <border_style> for the vertical border element left of
<row>, <col>.
"""
return self._set_border_style(self._y_borders, row, col, border_style)
def _set_border_style(
self,
borders: list[BorderStyle],
row: int,
col: int,
border_style: BorderStyle,
):
"""Set <border_style> for <row>, <col> in <borders>."""
border_index = self._border_index(row, col)
actual_borderstyle = borders[border_index]
if border_style.priority >= actual_borderstyle.priority:
borders[border_index] = border_style
def get_x_border(self, row: int, col: int) -> BorderStyle:
"""Get the horizontal border element above <row>, <col>.
Last grid line (below <nrows>) is the element above of <nrows+1>.
"""
return self._get_border(self._x_borders, row, col)
def get_y_border(self, row: int, col: int) -> BorderStyle:
"""Get the vertical border element left of <row>, <col>.
Last grid line (right of <ncols>) is the element left of <ncols+1>.
"""
return self._get_border(self._y_borders, row, col)
def _get_border(
self, borders: list[BorderStyle], row: int, col: int
) -> BorderStyle:
"""Get border element at <row>, <col> from <borders>."""
return borders[self._border_index(row, col)]
def _calc_col_pos(self) -> list[float]:
"""Calculate the x-axis coords of the grid lines between the columns."""
col_pos: list[float] = []
start_x: float = self.table.insert.x
sum_fields(start_x, self.table.col_widths, col_pos.append)
return col_pos
def _calc_row_pos(self) -> list[float]:
"""Calculate the y-axis coords of the grid lines between the rows."""
row_pos: list[float] = []
start_y: float = self.table.insert.y
sum_fields(start_y, self.table.row_heights, row_pos.append, -1.0)
return row_pos
def cell_coords(
self, row: int, col: int, span: tuple[int, int]
) -> tuple[float, float, float, float]:
"""Get the coordinates of the cell <row>,<col> as absolute drawing units.
:return: a tuple (left, right, top, bottom)
"""
top = self.row_pos[row]
bottom = self.row_pos[row + span[0]]
left = self.col_pos[col]
right = self.col_pos[col + span[1]]
return left, right, top, bottom
def render_cell_background(
self, layout: GenericLayoutType, row: int, col: int, cell: Cell
):
"""Render the cell background for (row, col) as SOLID entity."""
style = cell.style
if style.bg_color is None:
return
# get cell coords in absolute drawing units
left, right, top, bottom = self.cell_coords(row, col, cell.span)
layout.add_solid(
points=((left, top), (left, bottom), (right, top), (right, bottom)),
dxfattribs={
"color": style.bg_color,
"layer": self.table.bg_layer_name,
},
)
def render_cell_content(
self, layout: GenericLayoutType, row: int, col: int, cell: Cell
):
"""Render the cell content for <row>,<col> into layout object."""
# get cell coords in absolute drawing units
coords = self.cell_coords(row, col, cell.span)
cell.render(layout, coords, self.table.fg_layer_name)
def render_lines(self, layout: GenericLayoutType, vm: VisibilityMap):
"""Render all grid lines into layout object."""
# Init borders with default_style top- and left border.
default_style = self.table.get_cell_style("default")
x_border = default_style.top
y_border = default_style.left
self._init_borders(x_border, y_border)
self._set_frames(self.table.frames)
self._set_borders(self.table.iter_visible_cells(vm))
self._render_borders(layout, self.table)
def _set_borders(self, visible_cells: Iterable[tuple[int, int, Cell]]):
"""Set borders of the visible cells."""
for row, col, cell in visible_cells:
bottom_row = row + cell.span[0]
right_col = col + cell.span[1]
self._set_rect_borders(row, bottom_row, col, right_col, cell.style)
self._set_inner_borders(
row, bottom_row, col, right_col, self.no_border
)
def _set_inner_borders(
self,
top_row: int,
bottom_row: int,
left_col: int,
right_col: int,
border_style: BorderStyle,
):
"""Set `border_style` to the inner borders of the rectangle (top_row,
bottom_row, ...)
"""
if bottom_row - top_row > 1:
for col in range(left_col, right_col):
for row in range(top_row + 1, bottom_row):
self.set_x_border(row, col, border_style)
if right_col - left_col > 1:
for row in range(top_row, bottom_row):
for col in range(left_col + 1, right_col):
self.set_y_border(row, col, border_style)
def _set_rect_borders(
self,
top_row: int,
bottom_row: int,
left_col: int,
right_col: int,
style: CellStyle,
):
"""Set border `style` to the rectangle (top_row, bottom_row, ...)
The values describing the grid lines between the cells, see doc-strings
for methods set_x_border() and set_y_border() and see comments for
self._x_borders and self._y_borders.
"""
for col in range(left_col, right_col):
self.set_x_border(top_row, col, style.top)
self.set_x_border(bottom_row, col, style.bottom)
for row in range(top_row, bottom_row):
self.set_y_border(row, left_col, style.left)
self.set_y_border(row, right_col, style.right)
def _set_frames(self, frames: Iterable[Frame]):
"""Set borders for all defined frames."""
for frame in frames:
top_row = frame.pos[0]
left_col = frame.pos[1]
bottom_row = top_row + frame.span[0]
right_col = left_col + frame.span[1]
self._set_rect_borders(
top_row, bottom_row, left_col, right_col, frame.style
)
def _render_borders(self, layout: GenericLayoutType, table: TablePainter):
"""Render the grid lines as LINE entities into layout object."""
def render_line(start: Vec2, end: Vec2, style: BorderStyle):
"""Render the LINE entity into layout object."""
if style.status:
layout.add_line(
start=start,
end=end,
dxfattribs={
"layer": layer,
"color": style.color,
"linetype": style.linetype,
"lineweight": style.lineweight,
},
)
def render_x_borders():
"""Draw the horizontal grid lines."""
for row in range(table.nrows + 1):
y = self.row_pos[row]
for col in range(table.ncols):
left = self.col_pos[col]
right = self.col_pos[col + 1]
style = self.get_x_border(row, col)
render_line(Vec2(left, y), Vec2(right, y), style)
def render_y_borders():
"""Draw the vertical grid lines."""
for col in range(table.ncols + 1):
x = self.col_pos[col]
for row in range(table.nrows):
top = self.row_pos[row]
bottom = self.row_pos[row + 1]
style = self.get_y_border(row, col)
render_line(Vec2(x, top), Vec2(x, bottom), style)
layer = table.grid_layer_name
render_x_borders()
render_y_borders()
class Frame:
"""Represent a rectangle cell area enclosed by borderlines.
Args:
table: the assigned data table
pos: tuple (row, col), border goes left and top of pos
span: count of cells that Frame covers, border goes right and below of this cells
style: style name as string
"""
def __init__(
self,
table: TablePainter,
pos: tuple[int, int] = (0, 0),
span: tuple[int, int] = (1, 1),
style="default",
):
self.table = table
self.pos = pos
self.span = span
self.stylename = style
@property
def style(self) -> CellStyle:
return self.table.get_cell_style(self.stylename)
class Cell:
"""Base class for table cells.
Args:
table: assigned data table
style: style name as string
span: tuple(spanrows, spancols), count of cells that cell covers
A cell doesn't know its own position in the data table, because a cell can
be used multiple times in the same or in different tables, therefore the
cell itself can not determine if the cell-range reaches beyond the table
borders.
"""
def __init__(
self,
table: TablePainter,
style="default",
span: tuple[int, int] = (1, 1),
):
self.table = table
self.stylename = style
# span values has to be >= 1
self.span = span
@property
def span(self) -> tuple[int, int]:
"""Get/set table span parameters."""
return self._span
@span.setter
def span(self, value: tuple[int, int]):
"""Ensures that span values are >= 1 in each direction."""
self._span = (max(1, value[0]), max(1, value[1]))
@property
def style(self) -> CellStyle:
"""Returns the associated :class:`CellStyle`."""
return self.table.get_cell_style(self.stylename)
def render(
self, layout: GenericLayoutType, coords: Sequence[float], layer: str
):
"""Renders the cell content into the given `layout`."""
pass
def get_workspace_coords(self, coords: Sequence[float]) -> Sequence[float]:
"""Reduces the cell-coords about the margin_x and the margin_y values."""
margin_x = self.style.margin_x
margin_y = self.style.margin_y
return (
coords[0] + margin_x, # left
coords[1] - margin_x, # right
coords[2] - margin_y, # top
coords[3] + margin_y, # bottom
)
CustomCell = Cell
class TextCell(Cell):
"""Implements a cell type containing a multi-line text. Uses the
:class:`~ezdxf.addons.MTextSurrogate` add-on to render the multi-line
text, therefore the content of these cells is compatible to DXF R12.
Args:
table: assigned data table
text: multi line text, lines separated by the new line character ``"\\n"``
style: cell style name as string
span: tuple(rows, cols) area of cells to cover
"""
def __init__(
self,
table: TablePainter,
text: str,
style="default",
span: tuple[int, int] = (1, 1),
):
super(TextCell, self).__init__(table, style, span)
self.text = text
def render(
self, layout: GenericLayoutType, coords: Sequence[float], layer: str
):
"""Text cell.
Args:
layout: target layout
coords: tuple of border-coordinates: left, right, top, bottom
layer: target layer name as string
"""
if not len(self.text):
return
left, right, top, bottom = self.get_workspace_coords(coords)
style = self.style
h_align, v_align = style.get_text_align_flags()
rotated = self.style.rotation
text = self.text
if style.stacked:
rotated = 0.0
text = "\n".join((char for char in self.text.replace("\n", " ")))
xpos = (left, float(left + right) / 2.0, right)[h_align]
ypos = (bottom, float(bottom + top) / 2.0, top)[v_align - 1]
mtext = MTextSurrogate(
text,
(xpos, ypos),
line_spacing=self.style.line_spacing,
style=self.style.text_style,
char_height=self.style.char_height,
rotation=rotated,
width_factor=self.style.scale_x,
align=style.align,
color=self.style.text_color,
layer=layer,
)
mtext.render(layout)
class BlockCell(Cell):
"""Implements a cell type containing a block reference.
Args:
table: table object
blockdef: :class:`ezdxf.layouts.BlockLayout` instance
attribs: BLOCK attributes as (tag, value) dictionary
style: cell style name as string
span: tuple(rows, cols) area of cells to cover
"""
def __init__(
self,
table: TablePainter,
blockdef: BlockLayout,
style="default",
attribs=None,
span: tuple[int, int] = (1, 1),
):
if attribs is None:
attribs = {}
super(BlockCell, self).__init__(table, style, span)
self.block_name = blockdef.name # dxf block name!
self.attribs = attribs
def render(
self, layout: GenericLayoutType, coords: Sequence[float], layer: str
):
"""Create the cell content as INSERT-entity with trailing ATTRIB-Entities.
Args:
layout: target layout
coords: tuple of border-coordinates : left, right, top, bottom
layer: target layer name as string
"""
left, right, top, bottom = self.get_workspace_coords(coords)
style = self.style
h_align, v_align = style.get_text_align_flags()
xpos = (left, float(left + right) / 2.0, right)[h_align]
ypos = (bottom, float(bottom + top) / 2.0, top)[v_align - 1]
layout.add_auto_blockref(
name=self.block_name,
insert=(xpos, ypos),
values=self.attribs,
dxfattribs={
"xscale": style.scale_x,
"yscale": style.scale_y,
"rotation": style.rotation,
"layer": layer,
},
)
def sum_fields(
start_value: float,
fields: list[float],
append: Callable[[float], None],
sign: float = 1.0,
):
"""Adds step-by-step the fields-values, starting with <start_value>,
and appends the resulting values to another object with the
append-method.
"""
position = start_value
append(position)
for element in fields:
position += element * sign
append(position)
@@ -0,0 +1,358 @@
# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Union
import enum
from ezdxf.entities import Text, Attrib, Hatch, DXFGraphic
from ezdxf.lldxf import const
from ezdxf.enums import TextEntityAlignment, MAP_TEXT_ENUM_TO_ALIGN_FLAGS
from ezdxf.math import Matrix44, BoundingBox
from ezdxf import path
from ezdxf.path import Path
from ezdxf.fonts import fonts
from ezdxf.query import EntityQuery
__all__ = [
"make_path_from_str",
"make_paths_from_str",
"make_hatches_from_str",
"make_path_from_entity",
"make_paths_from_entity",
"make_hatches_from_entity",
"virtual_entities",
"explode",
"Kind",
]
AnyText = Union[Text, Attrib]
VALID_TYPES = ("TEXT", "ATTRIB")
def make_path_from_str(
s: str,
font: fonts.FontFace,
size: float = 1.0,
align=TextEntityAlignment.LEFT,
length: float = 0,
m: Matrix44 = None,
) -> Path:
"""Convert a single line string `s` into a :term:`Multi-Path` object.
The text `size` is the height of the uppercase letter "X" (cap height).
The paths are aligned about the insertion point at (0, 0).
BASELINE means the bottom of the letter "X".
Args:
s: text to convert
font: font face definition as :class:`~ezdxf.tools.fonts.FontFace` object
size: text size (cap height) in drawing units
align: alignment as :class:`ezdxf.enums.TextEntityAlignment`,
default is :attr:`LEFT`
length: target length for the :attr:`ALIGNED` and :attr:`FIT` alignments
m: transformation :class:`~ezdxf.math.Matrix44`
"""
if len(s) == 0:
return Path()
abstract_font = get_font(font)
# scale font rendering units to drawing units:
p = _str_to_path(s, abstract_font, size)
bbox = path.bbox([p], fast=True)
# Text is rendered in drawing units,
# therefore do alignment in drawing units:
draw_units_fm = abstract_font.measurements.scale_from_baseline(size)
matrix = alignment_transformation(draw_units_fm, bbox, align, length)
if m is not None:
matrix *= m
return p.transform(matrix)
def make_paths_from_str(
s: str,
font: fonts.FontFace,
size: float = 1.0,
align=TextEntityAlignment.LEFT,
length: float = 0,
m: Matrix44 = None,
) -> list[Path]:
"""Convert a single line string `s` into a list of
:class:`~ezdxf.path.Path` objects. All paths are returned as a list of
:term:`Single-Path` objects.
The text `size` is the height of the uppercase letter "X" (cap height).
The paths are aligned about the insertion point at (0, 0).
BASELINE means the bottom of the letter "X".
Args:
s: text to convert
font: font face definition as :class:`~ezdxf.tools.fonts.FontFace` object
size: text size (cap height) in drawing units
align: alignment as :class:`ezdxf.enums.TextEntityAlignment`,
default is :attr:`LEFT`
length: target length for the :attr:`ALIGNED` and :attr:`FIT` alignments
m: transformation :class:`~ezdxf.math.Matrix44`
"""
if len(s) == 0:
return []
p = make_path_from_str(s, font, size, align, length, m)
return list(p.sub_paths())
def get_font(font: fonts.FontFace) -> fonts.AbstractFont:
font_name = fonts.font_manager.find_font_name(font)
return fonts.make_font(font_name, cap_height=1.0)
def _str_to_path(s: str, render_engine: fonts.AbstractFont, size: float = 1.0) -> Path:
return render_engine.text_path_ex(s, cap_height=size).to_path()
def alignment_transformation(
fm: fonts.FontMeasurements,
bbox: BoundingBox,
align: TextEntityAlignment,
length: float,
) -> Matrix44:
"""Returns the alignment transformation matrix to transform a basic
text path at location (0, 0) and alignment :attr:`LEFT` into the final text
path of the given alignment.
For the alignments :attr:`FIT` and :attr:`ALIGNED` defines the argument
`length` the total length of the final text path. The given bounding box
defines the rendering borders of the basic text path.
"""
halign, valign = MAP_TEXT_ENUM_TO_ALIGN_FLAGS[align]
matrix = basic_alignment_transformation(fm, bbox, halign, valign)
stretch_x = 1.0
stretch_y = 1.0
if align == TextEntityAlignment.ALIGNED:
stretch_x = length / bbox.size.x
stretch_y = stretch_x
elif align == TextEntityAlignment.FIT:
stretch_x = length / bbox.size.x
if stretch_x != 1.0:
matrix *= Matrix44.scale(stretch_x, stretch_y, 1.0)
return matrix
def basic_alignment_transformation(
fm: fonts.FontMeasurements, bbox: BoundingBox, halign: int, valign: int
) -> Matrix44:
if halign == const.LEFT:
shift_x = 0.0
elif halign == const.RIGHT:
assert bbox.extmax is not None, "invalid empty bounding box"
shift_x = -bbox.extmax.x
elif halign == const.CENTER or halign > 2: # ALIGNED, MIDDLE, FIT
assert bbox.center is not None, "invalid empty bounding box"
shift_x = -bbox.center.x
else:
raise ValueError(f"invalid halign argument: {halign}")
cap_height = fm.cap_height
descender_height = fm.descender_height
if valign == const.BASELINE:
shift_y = 0.0
elif valign == const.TOP:
shift_y = -cap_height
elif valign == const.MIDDLE:
shift_y = -cap_height / 2
elif valign == const.BOTTOM:
shift_y = descender_height
else:
raise ValueError(f"invalid valign argument: {valign}")
if halign == 4: # MIDDLE
shift_y = -cap_height + fm.total_height / 2.0
return Matrix44.translate(shift_x, shift_y, 0)
def make_hatches_from_str(
s: str,
font: fonts.FontFace,
size: float = 1.0,
align=TextEntityAlignment.LEFT,
length: float = 0,
dxfattribs=None,
m: Matrix44 = None,
) -> list[Hatch]:
"""Convert a single line string `s` into a list of virtual
:class:`~ezdxf.entities.Hatch` entities.
The text `size` is the height of the uppercase letter "X" (cap height).
The paths are aligned about the insertion point at (0, 0).
The HATCH entities are aligned to this insertion point. BASELINE means the
bottom of the letter "X".
.. important::
Returns an empty list for .shx, .shp and .lff fonts a.k.a. stroke fonts.
Args:
s: text to convert
font: font face definition as :class:`~ezdxf.tools.fonts.FontFace` object
size: text size (cap height) in drawing units
align: alignment as :class:`ezdxf.enums.TextEntityAlignment`,
default is :attr:`LEFT`
length: target length for the :attr:`ALIGNED` and :attr:`FIT` alignments
dxfattribs: additional DXF attributes
m: transformation :class:`~ezdxf.math.Matrix44`
"""
font_ = get_font(font)
if font_.font_render_type is fonts.FontRenderType.STROKE:
return []
# HATCH is an OCS entity, transforming just the polyline paths
# is not correct! The Hatch has to be created in the xy-plane!
paths = make_paths_from_str(s, font, size, align, length)
dxfattribs = dict(dxfattribs or {})
dxfattribs.setdefault("solid_fill", 1)
dxfattribs.setdefault("pattern_name", "SOLID")
dxfattribs.setdefault("color", const.BYLAYER)
hatches = path.to_hatches(paths, edge_path=True, dxfattribs=dxfattribs)
if m is not None:
# Transform HATCH entities as a unit:
return [hatch.transform(m) for hatch in hatches] # type: ignore
else:
return list(hatches)
def check_entity_type(entity):
if entity is None:
raise TypeError("entity is None")
elif not entity.dxftype() in VALID_TYPES:
raise TypeError(f"unsupported entity type: {entity.dxftype()}")
def make_path_from_entity(entity: AnyText) -> Path:
"""Convert text content from DXF entities TEXT and ATTRIB into a
:term:`Multi-Path` object.
The paths are located at the location of the source entity.
"""
check_entity_type(entity)
text = entity.plain_text()
p = make_path_from_str(
text,
fonts.get_font_face(entity.font_name()),
size=entity.dxf.height, # cap height in drawing units
align=entity.get_align_enum(),
length=entity.fit_length(),
)
m = entity.wcs_transformation_matrix()
return p.transform(m)
def make_paths_from_entity(entity: AnyText) -> list[Path]:
"""Convert text content from DXF entities TEXT and ATTRIB into a
list of :class:`~ezdxf.path.Path` objects. All paths are returned as a
list of :term:`Single-Path` objects.
The paths are located at the location of the source entity.
"""
return list(make_path_from_entity(entity).sub_paths())
def make_hatches_from_entity(entity: AnyText) -> list[Hatch]:
"""Convert text content from DXF entities TEXT and ATTRIB into a
list of virtual :class:`~ezdxf.entities.Hatch` entities.
The hatches are placed at the same location as the source entity and have
the same DXF attributes as the source entity.
"""
check_entity_type(entity)
extrusion = entity.dxf.extrusion
attribs = entity.graphic_properties()
paths = make_paths_from_entity(entity)
return list(
path.to_hatches(
paths,
edge_path=True,
extrusion=extrusion,
dxfattribs=attribs,
)
)
@enum.unique
class Kind(enum.IntEnum):
"""The :class:`Kind` enum defines the DXF types to create as bit flags,
e.g. 1+2 to get HATCHES as filling and SPLINES and POLYLINES as outline:
=== =========== ==============================
Int Enum Description
=== =========== ==============================
1 HATCHES :class:`~ezdxf.entities.Hatch` entities as filling
2 SPLINES :class:`~ezdxf.entities.Spline` and 3D :class:`~ezdxf.entities.Polyline`
entities as outline
4 LWPOLYLINES :class:`~ezdxf.entities.LWPolyline` entities as approximated
(flattened) outline
=== =========== ==============================
"""
HATCHES = 1
SPLINES = 2
LWPOLYLINES = 4
def virtual_entities(entity: AnyText, kind: int = Kind.HATCHES) -> EntityQuery:
"""Convert the text content of DXF entities TEXT and ATTRIB into virtual
SPLINE and 3D POLYLINE entities or approximated LWPOLYLINE entities
as outlines, or as HATCH entities as fillings.
Returns the virtual DXF entities as an :class:`~ezdxf.query.EntityQuery`
object.
Args:
entity: TEXT or ATTRIB entity
kind: kind of entities to create as bit flags, see enum :class:`Kind`
"""
check_entity_type(entity)
extrusion = entity.dxf.extrusion
attribs = entity.graphic_properties()
entities: list[DXFGraphic] = []
if kind & Kind.HATCHES:
entities.extend(make_hatches_from_entity(entity))
if kind & (Kind.SPLINES + Kind.LWPOLYLINES):
paths = make_paths_from_entity(entity)
if kind & Kind.SPLINES:
entities.extend(path.to_splines_and_polylines(paths, dxfattribs=attribs))
if kind & Kind.LWPOLYLINES:
entities.extend(
path.to_lwpolylines(paths, extrusion=extrusion, dxfattribs=attribs)
)
return EntityQuery(entities)
def explode(entity: AnyText, kind: int = Kind.HATCHES, target=None) -> EntityQuery:
"""Explode the text `entity` into virtual entities,
see :func:`virtual_entities`. The source entity will be destroyed.
The target layout is given by the `target` argument, if `target` is ``None``,
the target layout is the source layout of the text entity.
Returns the created DXF entities as an :class:`~ezdxf.query.EntityQuery`
object.
Args:
entity: TEXT or ATTRIB entity to explode
kind: kind of entities to create as bit flags, see enum :class:`Kind`
target: target layout for new created DXF entities, ``None`` for the
same layout as the source entity.
"""
entities = virtual_entities(entity, kind)
# Explicit check for None is required, because empty layouts are also False
if target is None:
target = entity.get_layout()
entity.destroy()
if target is not None:
for e in entities:
target.add_entity(e)
return EntityQuery(entities)
@@ -0,0 +1,90 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
"""xplayer = cross backend player."""
from __future__ import annotations
from typing import Callable
from ezdxf.math import Vec2
from ezdxf.colors import RGB
from ezdxf.addons.drawing.backend import BackendInterface, BkPath2d
from ezdxf.addons.drawing.properties import BackendProperties
from ezdxf.addons.hpgl2 import api as hpgl2
from ezdxf.addons.hpgl2.backend import (
Properties as HPGL2Properties,
RecordType as HPGL2RecordType,
)
def hpgl2_to_drawing(
player: hpgl2.Player,
backend: BackendInterface,
bg_color: str = "#ffffff",
override: Callable[[BackendProperties], BackendProperties] | None = None,
) -> None:
"""Replays the recordings of the HPGL2 Recorder on a backend of the drawing add-on."""
if bg_color:
backend.set_background(bg_color)
for record_type, properties, record_data in player.recordings():
backend_properties = _make_drawing_backend_properties(properties)
if override:
backend_properties = override(backend_properties)
if record_type == HPGL2RecordType.POLYLINE:
points: list[Vec2] = record_data.vertices()
size = len(points)
if size == 1:
backend.draw_point(points[0], backend_properties)
elif size == 2:
backend.draw_line(points[0], points[1], backend_properties)
else:
backend.draw_path(BkPath2d.from_vertices(points), backend_properties)
elif record_type == HPGL2RecordType.FILLED_PATHS:
backend.draw_filled_paths(record_data, backend_properties)
elif record_type == HPGL2RecordType.OUTLINE_PATHS:
for p in record_data:
backend.draw_path(p, backend_properties)
backend.finalize()
def _make_drawing_backend_properties(properties: HPGL2Properties) -> BackendProperties:
"""Make BackendProperties() for the drawing add-on."""
return BackendProperties(
color=properties.pen_color.to_hex(),
lineweight=properties.pen_width,
layer="0",
pen=properties.pen_index,
handle="",
)
def map_color(color: str) -> Callable[[BackendProperties], BackendProperties]:
def _map_color(properties: BackendProperties) -> BackendProperties:
return BackendProperties(
color=color,
lineweight=properties.lineweight,
layer=properties.layer,
pen=properties.pen,
handle=properties.handle,
)
return _map_color
def map_monochrome(dark_mode=True) -> Callable[[BackendProperties], BackendProperties]:
def to_gray(color: str) -> str:
gray = round(RGB.from_hex(color).luminance * 255)
if dark_mode:
gray = 255 - gray
return RGB(gray, gray, gray).to_hex()
def _map_color(properties: BackendProperties) -> BackendProperties:
color = properties.color
alpha = color[7:9]
return BackendProperties(
color=to_gray(color[:7]) + alpha,
lineweight=properties.lineweight,
layer=properties.layer,
pen=properties.pen,
handle=properties.handle,
)
return _map_color
@@ -0,0 +1,86 @@
# Copyright (c) 2021, Manfred Moitzi
# License: MIT License
# mypy: ignore_errors=True
from ezdxf._options import DRAWING_ADDON, options
# Qt compatibility layer: all Qt imports from ezdxf.addons.xqt
PYSIDE6 = False
TRY_PYSIDE6 = options.get_bool(DRAWING_ADDON, "try_pyside6", True)
PYQT5 = False
TRY_PYQT5 = options.get_bool(DRAWING_ADDON, "try_pyqt5", True)
if TRY_PYSIDE6:
try:
from PySide6 import QtGui, QtCore, QtWidgets
from PySide6.QtWidgets import (
QFileDialog,
QInputDialog,
QMessageBox,
QTableView,
QTreeView,
QListView,
)
from PySide6.QtCore import (
QAbstractTableModel,
QStringListModel,
QFileSystemWatcher,
QModelIndex,
QPointF,
QSettings,
QSize,
Qt,
Signal,
Slot,
)
from PySide6.QtGui import (
QAction,
QColor,
QPainterPath,
QStandardItem,
QStandardItemModel,
)
PYSIDE6 = True
print("using Qt binding: PySide6")
except ImportError:
pass
# PyQt5 is just a fallback
if TRY_PYQT5 and not PYSIDE6:
try:
from PyQt5 import QtGui, QtCore, QtWidgets
from PyQt5.QtCore import pyqtSignal as Signal
from PyQt5.QtCore import pyqtSlot as Slot
from PyQt5.QtWidgets import (
QAction,
QFileDialog,
QInputDialog,
QMessageBox,
QTableView,
QTreeView,
QListView,
)
from PyQt5.QtCore import (
QAbstractTableModel,
QStringListModel,
QFileSystemWatcher,
QModelIndex,
QPointF,
QSettings,
QSize,
Qt,
)
from PyQt5.QtGui import (
QColor,
QPainterPath,
QStandardItem,
QStandardItemModel,
)
PYQT5 = True
print("using Qt binding: PyQt5")
except ImportError:
pass
if not (PYSIDE6 or PYQT5):
raise ImportError("no Qt binding found, tried PySide6 and PyQt5")