This commit is contained in:
Christian Anetzberger
2026-01-22 20:23:51 +01:00
commit a197de9456
4327 changed files with 1235205 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,2 @@
# Copyright (c) 2022, Manfred Moitzi
# License: MIT License

View File

@@ -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)
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

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,6 @@
# Copyright (c) 2021, Manfred Moitzi
# License: MIT License
from .data import *
from .model import *
from .browser import *

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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"))

View File

@@ -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))

View File

@@ -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()

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,5 @@
# Copyright (c) 2020-2021, Matthew Broadway
# License: MIT License
from .frontend import Frontend
from .properties import Properties, RenderContext, LayerProperties

View File

@@ -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

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,492 @@
# 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)
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.new_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 new_shape(self):
return self.page.new_shape()
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.new_shape()
pos = Vec2(pos)
shape.draw_line(pos, pos)
self.finish_line(shape, properties, close=False)
shape.commit()
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
shape = self.new_shape()
shape.draw_line(Vec2(start), Vec2(end))
self.finish_line(shape, properties, close=False)
shape.commit()
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
shape = self.new_shape()
for start, end in lines:
shape.draw_line(start, end)
self.finish_line(shape, properties, close=False)
shape.commit()
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
if len(path) == 0:
return
shape = self.new_shape()
add_path_to_shape(shape, path, close=False)
self.finish_line(shape, properties, close=False)
shape.commit()
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
shape = self.new_shape()
for p in paths:
add_path_to_shape(shape, p, close=True)
self.finish_filling(shape, properties)
shape.commit()
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.new_shape()
shape.draw_polyline(vertices)
self.finish_filling(shape, properties)
shape.commit()
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:
pass
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

View File

@@ -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())

View File

@@ -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

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More