initial
This commit is contained in:
10
.venv/lib/python3.12/site-packages/ezdxf/addons/__init__.py
Normal file
10
.venv/lib/python3.12/site-packages/ezdxf/addons/__init__.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
779
.venv/lib/python3.12/site-packages/ezdxf/addons/acadctb.py
Normal file
779
.venv/lib/python3.12/site-packages/ezdxf/addons/acadctb.py
Normal 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 object’s color or
|
||||
assign a plot style color.
|
||||
|
||||
"""
|
||||
return bool(self._color_policy & DITHERING_ON)
|
||||
|
||||
@dithering.setter
|
||||
def dithering(self, status: bool) -> None:
|
||||
if status:
|
||||
self._color_policy |= DITHERING_ON
|
||||
else:
|
||||
self._color_policy &= ~DITHERING_ON
|
||||
|
||||
@property
|
||||
def grayscale(self) -> bool:
|
||||
"""Plot colors in grayscale. (bool)"""
|
||||
return bool(self._color_policy & GRAYSCALE_ON)
|
||||
|
||||
@grayscale.setter
|
||||
def grayscale(self, status: bool) -> None:
|
||||
if status:
|
||||
self._color_policy |= GRAYSCALE_ON
|
||||
else:
|
||||
self._color_policy &= ~GRAYSCALE_ON
|
||||
|
||||
@property
|
||||
def named_color(self) -> bool:
|
||||
return bool(self._color_policy & NAMED_COLOR)
|
||||
|
||||
@named_color.setter
|
||||
def named_color(self, status: bool) -> None:
|
||||
if status:
|
||||
self._color_policy |= NAMED_COLOR
|
||||
else:
|
||||
self._color_policy &= ~NAMED_COLOR
|
||||
|
||||
def write(self, stream: TextIO) -> None:
|
||||
"""Write style data to file-like object `stream`."""
|
||||
index = self.index
|
||||
stream.write(" %d{\n" % index)
|
||||
stream.write(' name="%s\n' % self.name)
|
||||
stream.write(' localized_name="%s\n' % self.localized_name)
|
||||
stream.write(' description="%s\n' % self.description)
|
||||
stream.write(" color=%d\n" % self._color)
|
||||
if self._color != OBJECT_COLOR:
|
||||
stream.write(" mode_color=%d\n" % self._mode_color)
|
||||
stream.write(" color_policy=%d\n" % self._color_policy)
|
||||
stream.write(" physical_pen_number=%d\n" % self.physical_pen_number)
|
||||
stream.write(" virtual_pen_number=%d\n" % self.virtual_pen_number)
|
||||
stream.write(" screen=%d\n" % self.screen)
|
||||
stream.write(" linepattern_size=%s\n" % str(self.linepattern_size))
|
||||
stream.write(" linetype=%d\n" % self.linetype)
|
||||
stream.write(
|
||||
" adaptive_linetype=%s\n" % str(bool(self.adaptive_linetype)).upper()
|
||||
)
|
||||
stream.write(" lineweight=%s\n" % str(self.lineweight))
|
||||
stream.write(" fill_style=%d\n" % self.fill_style)
|
||||
stream.write(" end_style=%d\n" % self.end_style)
|
||||
stream.write(" join_style=%d\n" % self.join_style)
|
||||
stream.write(" }\n")
|
||||
|
||||
|
||||
class PlotStyleTable:
|
||||
"""PlotStyle container"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: str = "",
|
||||
scale_factor: float = 1.0,
|
||||
apply_factor: bool = False,
|
||||
):
|
||||
self.description = description
|
||||
self.scale_factor = scale_factor
|
||||
self.apply_factor = apply_factor
|
||||
|
||||
# set custom_lineweight_display_units to 1 for showing lineweight in inch in
|
||||
# AutoCAD CTB editor window, but lineweight is always defined in mm
|
||||
self.custom_lineweight_display_units = 0
|
||||
self.lineweights = array("f", DEFAULT_LINE_WEIGHTS)
|
||||
|
||||
def get_lineweight_index(self, lineweight: float) -> int:
|
||||
"""Get index of `lineweight` in the lineweight table or append
|
||||
`lineweight` to lineweight table.
|
||||
"""
|
||||
try:
|
||||
return self.lineweights.index(lineweight)
|
||||
except ValueError:
|
||||
self.lineweights.append(lineweight)
|
||||
return len(self.lineweights) - 1
|
||||
|
||||
def set_table_lineweight(self, index: int, lineweight: float) -> int:
|
||||
"""Argument `index` is the lineweight table index, not the :ref:`ACI`.
|
||||
|
||||
Args:
|
||||
index: lineweight table index = :attr:`PlotStyle.lineweight`
|
||||
lineweight: in millimeters
|
||||
|
||||
"""
|
||||
try:
|
||||
self.lineweights[index] = lineweight
|
||||
return index
|
||||
except IndexError:
|
||||
self.lineweights.append(lineweight)
|
||||
return len(self.lineweights) - 1
|
||||
|
||||
def get_table_lineweight(self, index: int) -> float:
|
||||
"""Returns lineweight in millimeters of lineweight table entry `index`.
|
||||
|
||||
Args:
|
||||
index: lineweight table index = :attr:`PlotStyle.lineweight`
|
||||
|
||||
Returns:
|
||||
lineweight in mm or ``0.0`` for use entity lineweight
|
||||
|
||||
"""
|
||||
return self.lineweights[index]
|
||||
|
||||
def save(self, filename: str | os.PathLike) -> None:
|
||||
"""Save CTB or STB file as `filename` to the file system."""
|
||||
with open(filename, "wb") as stream:
|
||||
self.write(stream)
|
||||
|
||||
def write(self, stream: BinaryIO) -> None:
|
||||
"""Compress and write the CTB or STB file to binary `stream`."""
|
||||
memfile = StringIO()
|
||||
self.write_content(memfile)
|
||||
memfile.write(chr(0)) # end of file
|
||||
body = memfile.getvalue()
|
||||
memfile.close()
|
||||
_compress(stream, body)
|
||||
|
||||
@abstractmethod
|
||||
def write_content(self, stream: TextIO) -> None:
|
||||
pass
|
||||
|
||||
def _write_lineweights(self, stream: TextIO) -> None:
|
||||
"""Write custom lineweight table to text `stream`."""
|
||||
stream.write("custom_lineweight_table{\n")
|
||||
for index, weight in enumerate(self.lineweights):
|
||||
stream.write(" %d=%.2f\n" % (index, weight))
|
||||
stream.write("}\n")
|
||||
|
||||
def parse(self, text: str) -> None:
|
||||
"""Parse plot styles from CTB string `text`."""
|
||||
|
||||
def set_lineweights(lineweights):
|
||||
if lineweights is None:
|
||||
return
|
||||
self.lineweights = array("f", [0.0] * len(lineweights))
|
||||
for key, value in lineweights.items():
|
||||
self.lineweights[int(key)] = float(value)
|
||||
|
||||
parser = PlotStyleFileParser(text)
|
||||
self.description = parser.get("description", "")
|
||||
self.scale_factor = float(parser.get("scale_factor", 1.0))
|
||||
self.apply_factor = get_bool(parser.get("apply_factor", True))
|
||||
self.custom_lineweight_display_units = int(
|
||||
parser.get("custom_lineweight_display_units", 0)
|
||||
)
|
||||
set_lineweights(parser.get("custom_lineweight_table", None))
|
||||
self.load_styles(parser.get("plot_style", {}))
|
||||
|
||||
@abstractmethod
|
||||
def load_styles(self, styles):
|
||||
pass
|
||||
|
||||
|
||||
class ColorDependentPlotStyles(PlotStyleTable):
|
||||
def __init__(
|
||||
self,
|
||||
description: str = "",
|
||||
scale_factor: float = 1.0,
|
||||
apply_factor: bool = False,
|
||||
):
|
||||
super().__init__(description, scale_factor, apply_factor)
|
||||
self._styles: list[PlotStyle] = [
|
||||
PlotStyle(index, parent=self) for index in range(STYLE_COUNT)
|
||||
]
|
||||
self._styles.insert(
|
||||
0, PlotStyle(256)
|
||||
) # 1-based array: insert dummy value for index 0
|
||||
|
||||
def __getitem__(self, aci: int) -> PlotStyle:
|
||||
"""Returns :class:`PlotStyle` for :ref:`ACI` `aci`."""
|
||||
if 0 < aci < 256:
|
||||
return self._styles[aci]
|
||||
else:
|
||||
raise IndexError(aci)
|
||||
|
||||
def __setitem__(self, aci: int, style: PlotStyle):
|
||||
"""Set plot `style` for `aci`."""
|
||||
if 0 < aci < 256:
|
||||
style.parent = self
|
||||
self._styles[aci] = style
|
||||
else:
|
||||
raise IndexError(aci)
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterable of all plot styles."""
|
||||
return iter(self._styles[1:])
|
||||
|
||||
def new_style(self, aci: int, data: Optional[dict] = None) -> PlotStyle:
|
||||
"""Set `aci` to new attributes defined by `data` dict.
|
||||
|
||||
Args:
|
||||
aci: :ref:`ACI`
|
||||
data: ``dict`` of :class:`PlotStyle` attributes: description, color,
|
||||
physical_pen_number, virtual_pen_number, screen,
|
||||
linepattern_size, linetype, adaptive_linetype,
|
||||
lineweight, end_style, join_style, fill_style
|
||||
|
||||
"""
|
||||
# ctb table index = aci - 1
|
||||
# ctb table starts with index 0, where aci == 0 means BYBLOCK
|
||||
style = PlotStyle(index=aci - 1, data=data)
|
||||
style.color_type = COLOR_RGB
|
||||
self[aci] = style
|
||||
return style
|
||||
|
||||
def get_lineweight(self, aci: int):
|
||||
"""Returns the assigned lineweight for :class:`PlotStyle` `aci` in
|
||||
millimeter.
|
||||
"""
|
||||
style = self[aci]
|
||||
lineweight = style.get_lineweight()
|
||||
if lineweight == 0.0:
|
||||
return None
|
||||
else:
|
||||
return lineweight
|
||||
|
||||
def write_content(self, stream: TextIO) -> None:
|
||||
"""Write the CTB-file to text `stream`."""
|
||||
self._write_header(stream)
|
||||
self._write_aci_table(stream)
|
||||
self._write_plot_styles(stream)
|
||||
self._write_lineweights(stream)
|
||||
|
||||
def _write_header(self, stream: TextIO) -> None:
|
||||
"""Write header values of CTB-file to text `stream`."""
|
||||
stream.write('description="%s\n' % self.description)
|
||||
stream.write("aci_table_available=TRUE\n")
|
||||
stream.write("scale_factor=%.1f\n" % self.scale_factor)
|
||||
stream.write("apply_factor=%s\n" % str(self.apply_factor).upper())
|
||||
stream.write(
|
||||
"custom_lineweight_display_units=%s\n"
|
||||
% str(self.custom_lineweight_display_units)
|
||||
)
|
||||
|
||||
def _write_aci_table(self, stream: TextIO) -> None:
|
||||
"""Write AutoCAD Color Index table to text `stream`."""
|
||||
stream.write("aci_table{\n")
|
||||
for style in self:
|
||||
index = style.index
|
||||
stream.write(' %d="%s\n' % (index, color_name(index)))
|
||||
stream.write("}\n")
|
||||
|
||||
def _write_plot_styles(self, stream: TextIO) -> None:
|
||||
"""Write user styles to text `stream`."""
|
||||
stream.write("plot_style{\n")
|
||||
for style in self:
|
||||
style.write(stream)
|
||||
stream.write("}\n")
|
||||
|
||||
def load_styles(self, styles):
|
||||
for index, style in styles.items():
|
||||
index = int(index)
|
||||
style = PlotStyle(index, style)
|
||||
style.color_type = COLOR_RGB
|
||||
aci = index + 1
|
||||
self[aci] = style
|
||||
|
||||
|
||||
class NamedPlotStyles(PlotStyleTable):
|
||||
def __init__(
|
||||
self,
|
||||
description: str = "",
|
||||
scale_factor: float = 1.0,
|
||||
apply_factor: bool = False,
|
||||
):
|
||||
super().__init__(description, scale_factor, apply_factor)
|
||||
normal = PlotStyle(
|
||||
0,
|
||||
data={
|
||||
"name": "Normal",
|
||||
"localized_name": "Normal",
|
||||
},
|
||||
)
|
||||
self._styles: dict[str, PlotStyle] = {"Normal": normal}
|
||||
|
||||
def __iter__(self) -> Iterable[str]:
|
||||
"""Iterable of all plot style names."""
|
||||
return self.keys()
|
||||
|
||||
def __getitem__(self, name: str) -> PlotStyle:
|
||||
"""Returns :class:`PlotStyle` by `name`."""
|
||||
return self._styles[name]
|
||||
|
||||
def __delitem__(self, name: str) -> None:
|
||||
"""Delete plot style `name`. Plot style ``'Normal'`` is not deletable."""
|
||||
if name != "Normal":
|
||||
del self._styles[name]
|
||||
else:
|
||||
raise ValueError("Can't delete plot style 'Normal'. ")
|
||||
|
||||
def keys(self) -> Iterable[str]:
|
||||
"""Iterable of all plot style names."""
|
||||
keys = set(self._styles.keys())
|
||||
keys.discard("Normal")
|
||||
result = ["Normal"]
|
||||
result.extend(sorted(keys))
|
||||
return iter(result)
|
||||
|
||||
def items(self) -> Iterator[tuple[str, PlotStyle]]:
|
||||
"""Iterable of all plot styles as (``name``, class:`PlotStyle`) tuples."""
|
||||
for key in self.keys():
|
||||
yield key, self._styles[key]
|
||||
|
||||
def values(self) -> Iterable[PlotStyle]:
|
||||
"""Iterable of all class:`PlotStyle` objects."""
|
||||
for key, value in self.items():
|
||||
yield value
|
||||
|
||||
def new_style(
|
||||
self,
|
||||
name: str,
|
||||
data: Optional[dict] = None,
|
||||
localized_name: Optional[str] = None,
|
||||
) -> PlotStyle:
|
||||
"""Create new class:`PlotStyle` `name` by attribute dict `data`, replaces
|
||||
existing class:`PlotStyle` objects.
|
||||
|
||||
Args:
|
||||
name: plot style name
|
||||
localized_name: name shown in plot style editor, uses `name` if ``None``
|
||||
data: ``dict`` of :class:`PlotStyle` attributes: description, color,
|
||||
physical_pen_number, virtual_pen_number, screen,
|
||||
linepattern_size, linetype, adaptive_linetype, lineweight,
|
||||
end_style, join_style, fill_style
|
||||
|
||||
"""
|
||||
if name.lower() == "Normal":
|
||||
raise ValueError("Can't replace or modify plot style 'Normal'. ")
|
||||
data = data or {}
|
||||
data["name"] = name
|
||||
data["localized_name"] = localized_name or name
|
||||
index = len(self._styles)
|
||||
style = PlotStyle(index=index, data=data, parent=self)
|
||||
style.color_type = COLOR_ACI
|
||||
style.named_color = True
|
||||
self._styles[name] = style
|
||||
return style
|
||||
|
||||
def get_lineweight(self, name: str):
|
||||
"""Returns the assigned lineweight for :class:`PlotStyle` `name` in
|
||||
millimeter.
|
||||
"""
|
||||
style = self[name]
|
||||
lineweight = style.get_lineweight()
|
||||
if lineweight == 0.0:
|
||||
return None
|
||||
else:
|
||||
return lineweight
|
||||
|
||||
def write_content(self, stream: TextIO) -> None:
|
||||
"""Write the STB-file to text `stream`."""
|
||||
self._write_header(stream)
|
||||
self._write_plot_styles(stream)
|
||||
self._write_lineweights(stream)
|
||||
|
||||
def _write_header(self, stream: TextIO) -> None:
|
||||
"""Write header values of CTB-file to text `stream`."""
|
||||
stream.write('description="%s\n' % self.description)
|
||||
stream.write("aci_table_available=FALSE\n")
|
||||
stream.write("scale_factor=%.1f\n" % self.scale_factor)
|
||||
stream.write("apply_factor=%s\n" % str(self.apply_factor).upper())
|
||||
stream.write(
|
||||
"custom_lineweight_display_units=%s\n"
|
||||
% str(self.custom_lineweight_display_units)
|
||||
)
|
||||
|
||||
def _write_plot_styles(self, stream: TextIO) -> None:
|
||||
"""Write user styles to text `stream`."""
|
||||
stream.write("plot_style{\n")
|
||||
for index, style in enumerate(self.values()):
|
||||
style.index = index
|
||||
style.write(stream)
|
||||
stream.write("}\n")
|
||||
|
||||
def load_styles(self, styles):
|
||||
for index, style in styles.items():
|
||||
index = int(index)
|
||||
style = PlotStyle(index, style)
|
||||
style.color_type = COLOR_ACI
|
||||
self._styles[style.name] = style
|
||||
|
||||
|
||||
def _read_ctb(stream: BinaryIO) -> ColorDependentPlotStyles:
|
||||
"""Read a CTB-file from binary `stream`."""
|
||||
content: bytes = _decompress(stream)
|
||||
styles = ColorDependentPlotStyles()
|
||||
styles.parse(content.decode())
|
||||
return styles
|
||||
|
||||
|
||||
def _read_stb(stream: BinaryIO) -> NamedPlotStyles:
|
||||
"""Read a STB-file from binary `stream`."""
|
||||
content: bytes = _decompress(stream)
|
||||
styles = NamedPlotStyles()
|
||||
styles.parse(content.decode())
|
||||
return styles
|
||||
|
||||
|
||||
def load(
|
||||
filename: str | os.PathLike,
|
||||
) -> Union[ColorDependentPlotStyles, NamedPlotStyles]:
|
||||
"""Load the CTB or STB file `filename` from file system."""
|
||||
filename = str(filename)
|
||||
with open(filename, "rb") as stream:
|
||||
if filename.lower().endswith(".ctb"):
|
||||
return _read_ctb(stream)
|
||||
elif filename.lower().endswith(".stb"):
|
||||
return _read_stb(stream)
|
||||
else:
|
||||
raise ValueError('Invalid file type: "{}"'.format(filename))
|
||||
|
||||
|
||||
def new_ctb() -> ColorDependentPlotStyles:
|
||||
"""Create a new CTB file."""
|
||||
return ColorDependentPlotStyles()
|
||||
|
||||
|
||||
def new_stb() -> NamedPlotStyles:
|
||||
"""Create a new STB file."""
|
||||
return NamedPlotStyles()
|
||||
|
||||
|
||||
def _decompress(stream: BinaryIO) -> bytes:
|
||||
"""Read and decompress the file content from binray `stream`."""
|
||||
content = stream.read()
|
||||
data = zlib.decompress(content[60:]) # type: bytes
|
||||
return data[:-1] # truncate trailing \nul
|
||||
|
||||
|
||||
def _compress(stream: BinaryIO, content: str):
|
||||
"""Compress `content` and write to binary `stream`."""
|
||||
comp_body = zlib.compress(content.encode())
|
||||
adler_chksum = zlib.adler32(comp_body)
|
||||
stream.write(b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec")
|
||||
stream.write(pack("LLL", adler_chksum, len(content), len(comp_body)))
|
||||
stream.write(comp_body)
|
||||
|
||||
|
||||
class PlotStyleFileParser:
|
||||
"""A very simple CTB/STB file parser. CTB/STB files are created by
|
||||
applications, so the file structure should be correct in the most cases.
|
||||
"""
|
||||
|
||||
def __init__(self, text: str):
|
||||
self.data = {}
|
||||
for element, value in PlotStyleFileParser.iteritems(text):
|
||||
self.data[element] = value
|
||||
|
||||
@staticmethod
|
||||
def iteritems(text: str):
|
||||
"""Iterate over all first level (start at col 0) elements."""
|
||||
line_index = 0
|
||||
|
||||
def get_name() -> str:
|
||||
"""Get element name of line <line_index>."""
|
||||
line = lines[line_index]
|
||||
if line.endswith("{"): # start of a list like 'plot_style{'
|
||||
name = line[:-1]
|
||||
else: # simple name=value line
|
||||
name = line.split("=", 1)[0]
|
||||
return name.strip()
|
||||
|
||||
def get_mapping() -> dict:
|
||||
"""Get mapping of elements enclosed by { }.
|
||||
|
||||
e. g. lineweights, plot_styles, aci_table
|
||||
|
||||
"""
|
||||
|
||||
def end_of_list():
|
||||
return lines[line_index].endswith("}")
|
||||
|
||||
nonlocal line_index
|
||||
data = dict()
|
||||
while not end_of_list():
|
||||
name = get_name()
|
||||
value = get_value() # get value or sub-list
|
||||
data[name] = value
|
||||
line_index += 1
|
||||
return data # skip '}' - end of list
|
||||
|
||||
def get_value() -> Union[str, dict]:
|
||||
"""Get value of line <line_index> or the list that starts in line
|
||||
<line_index>.
|
||||
"""
|
||||
nonlocal line_index
|
||||
line = lines[line_index]
|
||||
if line.endswith("{"): # start of a list
|
||||
line_index += 1
|
||||
return get_mapping()
|
||||
else: # it's a simple name=value line
|
||||
value: str = line.split("=", 1)[1]
|
||||
value = sanitized_value(value)
|
||||
line_index += 1
|
||||
return value
|
||||
|
||||
def skip_empty_lines():
|
||||
nonlocal line_index
|
||||
while line_index < len(lines) and len(lines[line_index]) == 0:
|
||||
line_index += 1
|
||||
|
||||
lines = text.split("\n")
|
||||
while line_index < len(lines):
|
||||
name = get_name()
|
||||
value = get_value()
|
||||
yield name, value
|
||||
skip_empty_lines()
|
||||
|
||||
def get(self, name: str, default: Any) -> Any:
|
||||
return self.data.get(name, default)
|
||||
|
||||
|
||||
def sanitized_value(value: str) -> str:
|
||||
value = value.strip()
|
||||
if value.startswith('"'): # strings: <name>="string
|
||||
return value[1:]
|
||||
|
||||
# remove unknown appendix like this: "0.0076200000000 (+7.Z+"8V?S_LC )"
|
||||
# the pattern is "<float|int> (<some data>)", see issue #1069
|
||||
if value.endswith(")"):
|
||||
return value.split(" ")[0]
|
||||
return value
|
||||
|
||||
|
||||
def int2color(color: int) -> tuple[int, int, int, int]:
|
||||
"""Convert color integer value from CTB-file to ``(r, g, b, color_type)
|
||||
tuple.
|
||||
"""
|
||||
# Take color from layer, ignore other bytes.
|
||||
color_type = (color & 0xFF000000) >> 24
|
||||
red = (color & 0xFF0000) >> 16
|
||||
green = (color & 0xFF00) >> 8
|
||||
blue = color & 0xFF
|
||||
return red, green, blue, color_type
|
||||
|
||||
|
||||
def mode_color2int(red: int, green: int, blue: int, color_type=COLOR_RGB) -> int:
|
||||
"""Convert mode_color (r, g, b, color_type) tuple to integer."""
|
||||
return -color2int(red, green, blue, color_type)
|
||||
|
||||
|
||||
def color2int(red: int, green: int, blue: int, color_type: int) -> int:
|
||||
"""Convert color (r, g, b, color_type) to integer."""
|
||||
return -((color_type << 24) + (red << 16) + (green << 8) + blue) & 0xFFFFFFFF
|
||||
@@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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)
|
||||
716
.venv/lib/python3.12/site-packages/ezdxf/addons/binpacking.py
Normal file
716
.venv/lib/python3.12/site-packages/ezdxf/addons/binpacking.py
Normal 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)
|
||||
@@ -0,0 +1,6 @@
|
||||
# Copyright (c) 2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from .data import *
|
||||
from .model import *
|
||||
from .browser import *
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2021, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
|
||||
class Bookmark(NamedTuple):
|
||||
name: str
|
||||
handle: str
|
||||
offset: int
|
||||
|
||||
|
||||
class Bookmarks:
|
||||
def __init__(self) -> None:
|
||||
self.bookmarks: dict[str, Bookmark] = dict()
|
||||
|
||||
def add(self, name: str, handle: str, offset: int):
|
||||
self.bookmarks[name] = Bookmark(name, handle, offset)
|
||||
|
||||
def get(self, name: str) -> Optional[Bookmark]:
|
||||
return self.bookmarks.get(name)
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self.bookmarks.keys())
|
||||
|
||||
def discard(self, name: str):
|
||||
try:
|
||||
del self.bookmarks[name]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def clear(self):
|
||||
self.bookmarks.clear()
|
||||
@@ -0,0 +1,796 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Optional, Set
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import shlex
|
||||
|
||||
from ezdxf.addons.xqt import (
|
||||
QtWidgets,
|
||||
QtGui,
|
||||
QAction,
|
||||
QMessageBox,
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
Qt,
|
||||
QModelIndex,
|
||||
QSettings,
|
||||
QFileSystemWatcher,
|
||||
QSize,
|
||||
)
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.lldxf.const import DXFStructureError, DXFValueError
|
||||
from ezdxf.lldxf.types import DXFTag, is_pointer_code
|
||||
from ezdxf.lldxf.tags import Tags
|
||||
from ezdxf.addons.browser.reflinks import get_reference_link
|
||||
|
||||
from .model import (
|
||||
DXFStructureModel,
|
||||
DXFTagsModel,
|
||||
DXFTagsRole,
|
||||
)
|
||||
from .data import (
|
||||
DXFDocument,
|
||||
get_row_from_line_number,
|
||||
dxfstr,
|
||||
EntityHistory,
|
||||
SearchIndex,
|
||||
)
|
||||
from .views import StructureTree, DXFTagsTable
|
||||
from .find_dialog import Ui_FindDialog
|
||||
from .bookmarks import Bookmarks
|
||||
|
||||
__all__ = ["DXFStructureBrowser"]
|
||||
|
||||
APP_NAME = "DXF Structure Browser"
|
||||
BROWSE_COMMAND = ezdxf.options.BROWSE_COMMAND
|
||||
TEXT_EDITOR = ezdxf.options.get(BROWSE_COMMAND, "TEXT_EDITOR")
|
||||
ICON_SIZE = max(16, ezdxf.options.get_int(BROWSE_COMMAND, "ICON_SIZE"))
|
||||
|
||||
SearchSections = Set[str]
|
||||
|
||||
|
||||
def searchable_entities(
|
||||
doc: DXFDocument, search_sections: SearchSections
|
||||
) -> list[Tags]:
|
||||
entities: list[Tags] = []
|
||||
for name, section_entities in doc.sections.items():
|
||||
if name in search_sections:
|
||||
entities.extend(section_entities) # type: ignore
|
||||
return entities
|
||||
|
||||
|
||||
BROWSER_WIDTH = 1024
|
||||
BROWSER_HEIGHT = 768
|
||||
TREE_WIDTH_FACTOR = 0.33
|
||||
|
||||
|
||||
class DXFStructureBrowser(QtWidgets.QMainWindow):
|
||||
def __init__(
|
||||
self,
|
||||
filename: str = "",
|
||||
line: Optional[int] = None,
|
||||
handle: Optional[str] = None,
|
||||
resource_path: Path = Path("."),
|
||||
):
|
||||
super().__init__()
|
||||
self.doc = DXFDocument()
|
||||
self.resource_path = resource_path
|
||||
self._structure_tree = StructureTree()
|
||||
self._dxf_tags_table = DXFTagsTable()
|
||||
self._current_entity: Optional[Tags] = None
|
||||
self._active_search: Optional[SearchIndex] = None
|
||||
self._search_sections: set[str] = set()
|
||||
self._find_dialog: FindDialog = self.create_find_dialog()
|
||||
self._file_watcher = QFileSystemWatcher()
|
||||
self._exclusive_reload_dialog = True # see ask_for_reloading() method
|
||||
self.history = EntityHistory()
|
||||
self.bookmarks = Bookmarks()
|
||||
self.setup_actions()
|
||||
self.setup_menu()
|
||||
self.setup_toolbar()
|
||||
|
||||
if filename:
|
||||
self.load_dxf(filename)
|
||||
else:
|
||||
self.setWindowTitle(APP_NAME)
|
||||
|
||||
self.setCentralWidget(self.build_central_widget())
|
||||
self.resize(BROWSER_WIDTH, BROWSER_HEIGHT)
|
||||
self.connect_slots()
|
||||
if line is not None:
|
||||
try:
|
||||
line = int(line)
|
||||
except ValueError:
|
||||
print(f"Invalid line number: {line}")
|
||||
else:
|
||||
self.goto_line(line)
|
||||
if handle is not None:
|
||||
try:
|
||||
int(handle, 16)
|
||||
except ValueError:
|
||||
print(f"Given handle is not a hex value: {handle}")
|
||||
else:
|
||||
if not self.goto_handle(handle):
|
||||
print(f"Handle {handle} not found.")
|
||||
|
||||
def build_central_widget(self):
|
||||
container = QtWidgets.QSplitter(Qt.Horizontal)
|
||||
container.addWidget(self._structure_tree)
|
||||
container.addWidget(self._dxf_tags_table)
|
||||
tree_width = int(BROWSER_WIDTH * TREE_WIDTH_FACTOR)
|
||||
table_width = BROWSER_WIDTH - tree_width
|
||||
container.setSizes([tree_width, table_width])
|
||||
container.setCollapsible(0, False)
|
||||
container.setCollapsible(1, False)
|
||||
return container
|
||||
|
||||
def connect_slots(self):
|
||||
self._structure_tree.activated.connect(self.entity_activated)
|
||||
self._dxf_tags_table.activated.connect(self.tag_activated)
|
||||
# noinspection PyUnresolvedReferences
|
||||
self._file_watcher.fileChanged.connect(self.ask_for_reloading)
|
||||
|
||||
# noinspection PyAttributeOutsideInit
|
||||
def setup_actions(self):
|
||||
|
||||
self._open_action = self.make_action(
|
||||
"&Open DXF File...", self.open_dxf, shortcut="Ctrl+O"
|
||||
)
|
||||
self._export_entity_action = self.make_action(
|
||||
"&Export DXF Entity...", self.export_entity, shortcut="Ctrl+E"
|
||||
)
|
||||
self._copy_entity_action = self.make_action(
|
||||
"&Copy DXF Entity to Clipboard",
|
||||
self.copy_entity,
|
||||
shortcut="Shift+Ctrl+C",
|
||||
icon_name="icon-copy-64px.png",
|
||||
)
|
||||
self._copy_selected_tags_action = self.make_action(
|
||||
"&Copy selected DXF Tags to Clipboard",
|
||||
self.copy_selected_tags,
|
||||
shortcut="Ctrl+C",
|
||||
icon_name="icon-copy-64px.png",
|
||||
)
|
||||
self._quit_action = self.make_action(
|
||||
"&Quit", self.close, shortcut="Ctrl+Q"
|
||||
)
|
||||
self._goto_handle_action = self.make_action(
|
||||
"&Go to Handle...",
|
||||
self.ask_for_handle,
|
||||
shortcut="Ctrl+G",
|
||||
icon_name="icon-goto-handle-64px.png",
|
||||
tip="Go to Entity Handle",
|
||||
)
|
||||
self._goto_line_action = self.make_action(
|
||||
"Go to &Line...",
|
||||
self.ask_for_line_number,
|
||||
shortcut="Ctrl+L",
|
||||
icon_name="icon-goto-line-64px.png",
|
||||
tip="Go to Line Number",
|
||||
)
|
||||
|
||||
self._find_text_action = self.make_action(
|
||||
"Find &Text...",
|
||||
self.find_text,
|
||||
shortcut="Ctrl+F",
|
||||
icon_name="icon-find-64px.png",
|
||||
tip="Find Text in Entities",
|
||||
)
|
||||
self._goto_predecessor_entity_action = self.make_action(
|
||||
"&Previous Entity",
|
||||
self.goto_previous_entity,
|
||||
shortcut="Ctrl+Left",
|
||||
icon_name="icon-prev-entity-64px.png",
|
||||
tip="Go to Previous Entity in File Order",
|
||||
)
|
||||
|
||||
self._goto_next_entity_action = self.make_action(
|
||||
"&Next Entity",
|
||||
self.goto_next_entity,
|
||||
shortcut="Ctrl+Right",
|
||||
icon_name="icon-next-entity-64px.png",
|
||||
tip="Go to Next Entity in File Order",
|
||||
)
|
||||
self._entity_history_back_action = self.make_action(
|
||||
"Entity History &Back",
|
||||
self.go_back_entity_history,
|
||||
shortcut="Alt+Left",
|
||||
icon_name="icon-left-arrow-64px.png",
|
||||
tip="Go to Previous Entity in Browser History",
|
||||
)
|
||||
self._entity_history_forward_action = self.make_action(
|
||||
"Entity History &Forward",
|
||||
self.go_forward_entity_history,
|
||||
shortcut="Alt+Right",
|
||||
icon_name="icon-right-arrow-64px.png",
|
||||
tip="Go to Next Entity in Browser History",
|
||||
)
|
||||
self._open_entity_in_text_editor_action = self.make_action(
|
||||
"&Open in Text Editor",
|
||||
self.open_entity_in_text_editor,
|
||||
shortcut="Ctrl+T",
|
||||
)
|
||||
self._show_entity_in_tree_view_action = self.make_action(
|
||||
"Show Entity in Structure &Tree",
|
||||
self.show_current_entity_in_tree_view,
|
||||
shortcut="Ctrl+Down",
|
||||
icon_name="icon-show-in-tree-64px.png",
|
||||
tip="Show Current Entity in Structure Tree",
|
||||
)
|
||||
self._goto_header_action = self.make_action(
|
||||
"Go to HEADER Section",
|
||||
partial(self.go_to_section, name="HEADER"),
|
||||
shortcut="Shift+H",
|
||||
)
|
||||
self._goto_blocks_action = self.make_action(
|
||||
"Go to BLOCKS Section",
|
||||
partial(self.go_to_section, name="BLOCKS"),
|
||||
shortcut="Shift+B",
|
||||
)
|
||||
self._goto_entities_action = self.make_action(
|
||||
"Go to ENTITIES Section",
|
||||
partial(self.go_to_section, name="ENTITIES"),
|
||||
shortcut="Shift+E",
|
||||
)
|
||||
self._goto_objects_action = self.make_action(
|
||||
"Go to OBJECTS Section",
|
||||
partial(self.go_to_section, name="OBJECTS"),
|
||||
shortcut="Shift+O",
|
||||
)
|
||||
self._store_bookmark = self.make_action(
|
||||
"Store Bookmark...",
|
||||
self.store_bookmark,
|
||||
shortcut="Shift+Ctrl+B",
|
||||
icon_name="icon-store-bookmark-64px.png",
|
||||
)
|
||||
self._go_to_bookmark = self.make_action(
|
||||
"Go to Bookmark...",
|
||||
self.go_to_bookmark,
|
||||
shortcut="Ctrl+B",
|
||||
icon_name="icon-goto-bookmark-64px.png",
|
||||
)
|
||||
self._reload_action = self.make_action(
|
||||
"Reload DXF File",
|
||||
self.reload_dxf,
|
||||
shortcut="Ctrl+R",
|
||||
)
|
||||
|
||||
def make_action(
|
||||
self,
|
||||
name,
|
||||
slot,
|
||||
*,
|
||||
shortcut: str = "",
|
||||
icon_name: str = "",
|
||||
tip: str = "",
|
||||
) -> QAction:
|
||||
action = QAction(name, self)
|
||||
if shortcut:
|
||||
action.setShortcut(shortcut)
|
||||
if icon_name:
|
||||
icon = QtGui.QIcon(str(self.resource_path / icon_name))
|
||||
action.setIcon(icon)
|
||||
if tip:
|
||||
action.setToolTip(tip)
|
||||
action.triggered.connect(slot)
|
||||
return action
|
||||
|
||||
def setup_menu(self):
|
||||
menu = self.menuBar()
|
||||
file_menu = menu.addMenu("&File")
|
||||
file_menu.addAction(self._open_action)
|
||||
file_menu.addAction(self._reload_action)
|
||||
file_menu.addAction(self._open_entity_in_text_editor_action)
|
||||
file_menu.addSeparator()
|
||||
file_menu.addAction(self._copy_selected_tags_action)
|
||||
file_menu.addAction(self._copy_entity_action)
|
||||
file_menu.addAction(self._export_entity_action)
|
||||
file_menu.addSeparator()
|
||||
file_menu.addAction(self._quit_action)
|
||||
|
||||
navigate_menu = menu.addMenu("&Navigate")
|
||||
navigate_menu.addAction(self._goto_handle_action)
|
||||
navigate_menu.addAction(self._goto_line_action)
|
||||
navigate_menu.addAction(self._find_text_action)
|
||||
navigate_menu.addSeparator()
|
||||
navigate_menu.addAction(self._goto_next_entity_action)
|
||||
navigate_menu.addAction(self._goto_predecessor_entity_action)
|
||||
navigate_menu.addAction(self._show_entity_in_tree_view_action)
|
||||
navigate_menu.addSeparator()
|
||||
navigate_menu.addAction(self._entity_history_back_action)
|
||||
navigate_menu.addAction(self._entity_history_forward_action)
|
||||
navigate_menu.addSeparator()
|
||||
navigate_menu.addAction(self._goto_header_action)
|
||||
navigate_menu.addAction(self._goto_blocks_action)
|
||||
navigate_menu.addAction(self._goto_entities_action)
|
||||
navigate_menu.addAction(self._goto_objects_action)
|
||||
|
||||
bookmarks_menu = menu.addMenu("&Bookmarks")
|
||||
bookmarks_menu.addAction(self._store_bookmark)
|
||||
bookmarks_menu.addAction(self._go_to_bookmark)
|
||||
|
||||
def setup_toolbar(self) -> None:
|
||||
toolbar = QtWidgets.QToolBar("MainToolbar")
|
||||
toolbar.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
|
||||
toolbar.addAction(self._entity_history_back_action)
|
||||
toolbar.addAction(self._entity_history_forward_action)
|
||||
toolbar.addAction(self._goto_predecessor_entity_action)
|
||||
toolbar.addAction(self._goto_next_entity_action)
|
||||
toolbar.addAction(self._show_entity_in_tree_view_action)
|
||||
toolbar.addAction(self._find_text_action)
|
||||
toolbar.addAction(self._goto_line_action)
|
||||
toolbar.addAction(self._goto_handle_action)
|
||||
toolbar.addAction(self._store_bookmark)
|
||||
toolbar.addAction(self._go_to_bookmark)
|
||||
toolbar.addAction(self._copy_selected_tags_action)
|
||||
self.addToolBar(toolbar)
|
||||
|
||||
def create_find_dialog(self) -> "FindDialog":
|
||||
dialog = FindDialog()
|
||||
dialog.setModal(True)
|
||||
dialog.find_forward_button.clicked.connect(self.find_forward)
|
||||
dialog.find_backwards_button.clicked.connect(self.find_backwards)
|
||||
dialog.find_forward_button.setShortcut("F3")
|
||||
dialog.find_backwards_button.setShortcut("F4")
|
||||
return dialog
|
||||
|
||||
def open_dxf(self):
|
||||
path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||
self,
|
||||
caption="Select DXF file",
|
||||
filter="DXF Documents (*.dxf *.DXF)",
|
||||
)
|
||||
if path:
|
||||
self.load_dxf(path)
|
||||
|
||||
def load_dxf(self, path: str):
|
||||
try:
|
||||
self._load(path)
|
||||
except IOError as e:
|
||||
QMessageBox.critical(self, "Loading Error", str(e))
|
||||
except DXFStructureError as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"DXF Structure Error",
|
||||
f'Invalid DXF file "{path}": {str(e)}',
|
||||
)
|
||||
else:
|
||||
self.history.clear()
|
||||
self.view_header_section()
|
||||
self.update_title()
|
||||
|
||||
def reload_dxf(self):
|
||||
if self._current_entity is not None:
|
||||
entity = self.get_current_entity()
|
||||
handle = self.get_current_entity_handle()
|
||||
first_row = self._dxf_tags_table.first_selected_row()
|
||||
line_number = self.doc.get_line_number(entity, first_row)
|
||||
|
||||
self._load(self.doc.filename)
|
||||
if handle is not None:
|
||||
entity = self.doc.get_entity(handle)
|
||||
if entity is not None: # select entity with same handle
|
||||
self.set_current_entity_and_row_index(entity, first_row)
|
||||
self._structure_tree.expand_to_entity(entity)
|
||||
return
|
||||
# select entity at the same line number
|
||||
entity = self.doc.get_entity_at_line(line_number)
|
||||
self.set_current_entity_and_row_index(entity, first_row)
|
||||
self._structure_tree.expand_to_entity(entity)
|
||||
|
||||
def ask_for_reloading(self):
|
||||
if self.doc.filename and self._exclusive_reload_dialog:
|
||||
# Ignore further reload signals until first signal is processed.
|
||||
# Saving files by ezdxf triggers two "fileChanged" signals!?
|
||||
self._exclusive_reload_dialog = False
|
||||
ok = QMessageBox.question(
|
||||
self,
|
||||
"Reload",
|
||||
f'"{self.doc.absolute_filepath()}"\n\nThis file has been '
|
||||
f"modified by another program, reload file?",
|
||||
buttons=QMessageBox.Yes | QMessageBox.No,
|
||||
defaultButton=QMessageBox.Yes,
|
||||
)
|
||||
if ok == QMessageBox.Yes:
|
||||
self.reload_dxf()
|
||||
self._exclusive_reload_dialog = True
|
||||
|
||||
def _load(self, filename: str):
|
||||
if self.doc.filename:
|
||||
self._file_watcher.removePath(self.doc.filename)
|
||||
self.doc.load(filename)
|
||||
model = DXFStructureModel(self.doc.filepath.name, self.doc)
|
||||
self._structure_tree.set_structure(model)
|
||||
self.history.clear()
|
||||
self._file_watcher.addPath(self.doc.filename)
|
||||
|
||||
def export_entity(self):
|
||||
if self._dxf_tags_table is None:
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
caption="Export DXF Entity",
|
||||
filter="Text Files (*.txt *.TXT)",
|
||||
)
|
||||
if path:
|
||||
model = self._dxf_tags_table.model()
|
||||
tags = model.compiled_tags()
|
||||
self.export_tags(path, tags)
|
||||
|
||||
def copy_entity(self):
|
||||
if self._dxf_tags_table is None:
|
||||
return
|
||||
model = self._dxf_tags_table.model()
|
||||
tags = model.compiled_tags()
|
||||
copy_dxf_to_clipboard(tags)
|
||||
|
||||
def copy_selected_tags(self):
|
||||
if self._current_entity is None:
|
||||
return
|
||||
rows = self._dxf_tags_table.selected_rows()
|
||||
model = self._dxf_tags_table.model()
|
||||
tags = model.compiled_tags()
|
||||
try:
|
||||
export_tags = Tags(tags[row] for row in rows)
|
||||
except IndexError:
|
||||
return
|
||||
copy_dxf_to_clipboard(export_tags)
|
||||
|
||||
def view_header_section(self):
|
||||
header = self.doc.get_section("HEADER")
|
||||
if header:
|
||||
self.set_current_entity_with_history(header[0])
|
||||
else: # DXF R12 with only a ENTITIES section
|
||||
entities = self.doc.get_section("ENTITIES")
|
||||
if entities:
|
||||
self.set_current_entity_with_history(entities[1])
|
||||
|
||||
def update_title(self):
|
||||
self.setWindowTitle(f"{APP_NAME} - {self.doc.absolute_filepath()}")
|
||||
|
||||
def get_current_entity_handle(self) -> Optional[str]:
|
||||
active_entity = self.get_current_entity()
|
||||
if active_entity:
|
||||
try:
|
||||
return active_entity.get_handle()
|
||||
except DXFValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_current_entity(self) -> Optional[Tags]:
|
||||
return self._current_entity
|
||||
|
||||
def set_current_entity_by_handle(self, handle: str):
|
||||
entity = self.doc.get_entity(handle)
|
||||
if entity:
|
||||
self.set_current_entity(entity)
|
||||
|
||||
def set_current_entity(
|
||||
self, entity: Tags, select_line_number: Optional[int] = None
|
||||
):
|
||||
if entity:
|
||||
self._current_entity = entity
|
||||
start_line_number = self.doc.get_line_number(entity)
|
||||
model = DXFTagsModel(
|
||||
entity, start_line_number, self.doc.entity_index
|
||||
)
|
||||
self._dxf_tags_table.setModel(model)
|
||||
if select_line_number is not None:
|
||||
row = get_row_from_line_number(
|
||||
model.compiled_tags(), start_line_number, select_line_number
|
||||
)
|
||||
self._dxf_tags_table.selectRow(row)
|
||||
index = self._dxf_tags_table.model().index(row, 0)
|
||||
self._dxf_tags_table.scrollTo(index)
|
||||
|
||||
def set_current_entity_with_history(self, entity: Tags):
|
||||
self.set_current_entity(entity)
|
||||
self.history.append(entity)
|
||||
|
||||
def set_current_entity_and_row_index(self, entity: Tags, index: int):
|
||||
line = self.doc.get_line_number(entity, index)
|
||||
self.set_current_entity(entity, select_line_number=line)
|
||||
self.history.append(entity)
|
||||
|
||||
def entity_activated(self, index: QModelIndex):
|
||||
tags = index.data(role=DXFTagsRole)
|
||||
# PySide6: Tags() are converted to type list by PySide6?
|
||||
# print(type(tags))
|
||||
if isinstance(tags, (Tags, list)):
|
||||
self.set_current_entity_with_history(Tags(tags))
|
||||
|
||||
def tag_activated(self, index: QModelIndex):
|
||||
tag = index.data(role=DXFTagsRole)
|
||||
if isinstance(tag, DXFTag):
|
||||
code, value = tag
|
||||
if is_pointer_code(code):
|
||||
if not self.goto_handle(value):
|
||||
self.show_error_handle_not_found(value)
|
||||
elif code == 0:
|
||||
self.open_web_browser(get_reference_link(value))
|
||||
|
||||
def ask_for_handle(self):
|
||||
handle, ok = QInputDialog.getText(
|
||||
self,
|
||||
"Go to",
|
||||
"Go to entity handle:",
|
||||
)
|
||||
if ok:
|
||||
if not self.goto_handle(handle):
|
||||
self.show_error_handle_not_found(handle)
|
||||
|
||||
def goto_handle(self, handle: str) -> bool:
|
||||
entity = self.doc.get_entity(handle)
|
||||
if entity:
|
||||
self.set_current_entity_with_history(entity)
|
||||
return True
|
||||
return False
|
||||
|
||||
def show_error_handle_not_found(self, handle: str):
|
||||
QMessageBox.critical(self, "Error", f"Handle {handle} not found!")
|
||||
|
||||
def ask_for_line_number(self):
|
||||
max_line_number = self.doc.max_line_number
|
||||
number, ok = QInputDialog.getInt(
|
||||
self,
|
||||
"Go to",
|
||||
f"Go to line number: (max. {max_line_number})",
|
||||
1, # value
|
||||
1, # PyQt5: min, PySide6: minValue
|
||||
max_line_number, # PyQt5: max, PySide6: maxValue
|
||||
)
|
||||
if ok:
|
||||
self.goto_line(number)
|
||||
|
||||
def goto_line(self, number: int) -> bool:
|
||||
entity = self.doc.get_entity_at_line(int(number))
|
||||
if entity:
|
||||
self.set_current_entity(entity, number)
|
||||
return True
|
||||
return False
|
||||
|
||||
def find_text(self):
|
||||
self._active_search = None
|
||||
dialog = self._find_dialog
|
||||
dialog.restore_geometry()
|
||||
dialog.show_message("F3 searches forward, F4 searches backwards")
|
||||
dialog.find_text_edit.setFocus()
|
||||
dialog.show()
|
||||
|
||||
def update_search(self):
|
||||
def setup_search():
|
||||
self._search_sections = dialog.search_sections()
|
||||
entities = searchable_entities(self.doc, self._search_sections)
|
||||
self._active_search = SearchIndex(entities)
|
||||
|
||||
dialog = self._find_dialog
|
||||
if self._active_search is None:
|
||||
setup_search()
|
||||
# noinspection PyUnresolvedReferences
|
||||
self._active_search.set_current_entity(self._current_entity)
|
||||
else:
|
||||
search_sections = dialog.search_sections()
|
||||
if search_sections != self._search_sections:
|
||||
setup_search()
|
||||
dialog.update_options(self._active_search)
|
||||
|
||||
def find_forward(self):
|
||||
self._find(backward=False)
|
||||
|
||||
def find_backwards(self):
|
||||
self._find(backward=True)
|
||||
|
||||
def _find(self, backward=False):
|
||||
if self._find_dialog.isVisible():
|
||||
self.update_search()
|
||||
search = self._active_search
|
||||
if search.is_end_of_index:
|
||||
search.reset_cursor(backward=backward)
|
||||
|
||||
entity, index = (
|
||||
search.find_backwards() if backward else search.find_forward()
|
||||
)
|
||||
|
||||
if entity:
|
||||
self.set_current_entity_and_row_index(entity, index)
|
||||
self.show_entity_found_message(entity, index)
|
||||
else:
|
||||
if search.is_end_of_index:
|
||||
self.show_message("Not found and end of file!")
|
||||
else:
|
||||
self.show_message("Not found!")
|
||||
|
||||
def show_message(self, msg: str):
|
||||
self._find_dialog.show_message(msg)
|
||||
|
||||
def show_entity_found_message(self, entity: Tags, index: int):
|
||||
dxftype = entity.dxftype()
|
||||
if dxftype == "SECTION":
|
||||
tail = " @ {0} Section".format(entity.get_first_value(2))
|
||||
else:
|
||||
try:
|
||||
handle = entity.get_handle()
|
||||
tail = f" @ {dxftype}(#{handle})"
|
||||
except ValueError:
|
||||
tail = ""
|
||||
line = self.doc.get_line_number(entity, index)
|
||||
self.show_message(f"Found in Line: {line}{tail}")
|
||||
|
||||
def export_tags(self, filename: str, tags: Tags):
|
||||
try:
|
||||
with open(filename, "wt", encoding="utf8") as fp:
|
||||
fp.write(dxfstr(tags))
|
||||
except IOError as e:
|
||||
QMessageBox.critical(self, "IOError", str(e))
|
||||
|
||||
def goto_next_entity(self):
|
||||
if self._dxf_tags_table:
|
||||
current_entity = self.get_current_entity()
|
||||
if current_entity is not None:
|
||||
next_entity = self.doc.next_entity(current_entity)
|
||||
if next_entity is not None:
|
||||
self.set_current_entity_with_history(next_entity)
|
||||
|
||||
def goto_previous_entity(self):
|
||||
if self._dxf_tags_table:
|
||||
current_entity = self.get_current_entity()
|
||||
if current_entity is not None:
|
||||
prev_entity = self.doc.previous_entity(current_entity)
|
||||
if prev_entity is not None:
|
||||
self.set_current_entity_with_history(prev_entity)
|
||||
|
||||
def go_back_entity_history(self):
|
||||
entity = self.history.back()
|
||||
if entity is not None:
|
||||
self.set_current_entity(entity) # do not change history
|
||||
|
||||
def go_forward_entity_history(self):
|
||||
entity = self.history.forward()
|
||||
if entity is not None:
|
||||
self.set_current_entity(entity) # do not change history
|
||||
|
||||
def go_to_section(self, name: str):
|
||||
section = self.doc.get_section(name)
|
||||
if section:
|
||||
index = 0 if name == "HEADER" else 1
|
||||
self.set_current_entity_with_history(section[index])
|
||||
|
||||
def open_entity_in_text_editor(self):
|
||||
current_entity = self.get_current_entity()
|
||||
line_number = self.doc.get_line_number(current_entity)
|
||||
if self._dxf_tags_table:
|
||||
indices = self._dxf_tags_table.selectedIndexes()
|
||||
if indices:
|
||||
model = self._dxf_tags_table.model()
|
||||
row = indices[0].row()
|
||||
line_number = model.line_number(row)
|
||||
self._open_text_editor(
|
||||
str(self.doc.absolute_filepath()), line_number
|
||||
)
|
||||
|
||||
def _open_text_editor(self, filename: str, line_number: int) -> None:
|
||||
cmd = TEXT_EDITOR.format(
|
||||
filename=filename,
|
||||
num=line_number,
|
||||
)
|
||||
args = shlex.split(cmd)
|
||||
try:
|
||||
subprocess.Popen(args)
|
||||
except FileNotFoundError:
|
||||
QMessageBox.critical(
|
||||
self, "Text Editor", "Error calling text editor:\n" + cmd
|
||||
)
|
||||
|
||||
def open_web_browser(self, url: str):
|
||||
import webbrowser
|
||||
|
||||
webbrowser.open(url)
|
||||
|
||||
def show_current_entity_in_tree_view(self):
|
||||
entity = self.get_current_entity()
|
||||
if entity:
|
||||
self._structure_tree.expand_to_entity(entity)
|
||||
|
||||
def store_bookmark(self):
|
||||
if self._current_entity is not None:
|
||||
bookmarks = self.bookmarks.names()
|
||||
if len(bookmarks) == 0:
|
||||
bookmarks = ["0"]
|
||||
name, ok = QInputDialog.getItem(
|
||||
self,
|
||||
"Store Bookmark",
|
||||
"Bookmark:",
|
||||
bookmarks,
|
||||
editable=True,
|
||||
)
|
||||
if ok:
|
||||
entity = self._current_entity
|
||||
rows = self._dxf_tags_table.selectedIndexes()
|
||||
if rows:
|
||||
offset = rows[0].row()
|
||||
else:
|
||||
offset = 0
|
||||
handle = self.doc.get_handle(entity)
|
||||
self.bookmarks.add(name, handle, offset)
|
||||
|
||||
def go_to_bookmark(self):
|
||||
bookmarks = self.bookmarks.names()
|
||||
if len(bookmarks) == 0:
|
||||
QMessageBox.information(self, "Info", "No Bookmarks defined!")
|
||||
return
|
||||
|
||||
name, ok = QInputDialog.getItem(
|
||||
self,
|
||||
"Go to Bookmark",
|
||||
"Bookmark:",
|
||||
self.bookmarks.names(),
|
||||
editable=False,
|
||||
)
|
||||
if ok:
|
||||
bookmark = self.bookmarks.get(name)
|
||||
if bookmark is not None:
|
||||
self.set_current_entity_by_handle(bookmark.handle)
|
||||
self._dxf_tags_table.selectRow(bookmark.offset)
|
||||
model = self._dxf_tags_table.model()
|
||||
index = QModelIndex(model.index(bookmark.offset, 0))
|
||||
self._dxf_tags_table.scrollTo(index)
|
||||
|
||||
else:
|
||||
QtWidgets.QMessageBox.critical(
|
||||
self, "Bookmark not found!", str(name)
|
||||
)
|
||||
|
||||
|
||||
def copy_dxf_to_clipboard(tags: Tags):
|
||||
clipboard = QtWidgets.QApplication.clipboard()
|
||||
try:
|
||||
mode = clipboard.Mode.Clipboard
|
||||
except AttributeError:
|
||||
mode = clipboard.Clipboard # type: ignore # legacy location
|
||||
clipboard.setText(dxfstr(tags), mode=mode)
|
||||
|
||||
|
||||
class FindDialog(QtWidgets.QDialog, Ui_FindDialog):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setupUi(self)
|
||||
self.close_button.clicked.connect(lambda: self.close())
|
||||
self.settings = QSettings("ezdxf", "DXFBrowser")
|
||||
|
||||
def restore_geometry(self):
|
||||
geometry = self.settings.value("find.dialog.geometry")
|
||||
if geometry is not None:
|
||||
self.restoreGeometry(geometry)
|
||||
|
||||
def search_sections(self) -> SearchSections:
|
||||
sections = set()
|
||||
if self.header_check_box.isChecked():
|
||||
sections.add("HEADER")
|
||||
if self.classes_check_box.isChecked():
|
||||
sections.add("CLASSES")
|
||||
if self.tables_check_box.isChecked():
|
||||
sections.add("TABLES")
|
||||
if self.blocks_check_box.isChecked():
|
||||
sections.add("BLOCKS")
|
||||
if self.entities_check_box.isChecked():
|
||||
sections.add("ENTITIES")
|
||||
if self.objects_check_box.isChecked():
|
||||
sections.add("OBJECTS")
|
||||
return sections
|
||||
|
||||
def update_options(self, search: SearchIndex) -> None:
|
||||
search.reset_search_term(self.find_text_edit.text())
|
||||
search.case_insensitive = not self.match_case_check_box.isChecked()
|
||||
search.whole_words = self.whole_words_check_box.isChecked()
|
||||
search.numbers = self.number_tags_check_box.isChecked()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.settings.setValue("find.dialog.geometry", self.saveGeometry())
|
||||
super().closeEvent(event)
|
||||
|
||||
def show_message(self, msg: str):
|
||||
self.message.setText(msg)
|
||||
428
.venv/lib/python3.12/site-packages/ezdxf/addons/browser/data.py
Normal file
428
.venv/lib/python3.12/site-packages/ezdxf/addons/browser/data.py
Normal 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
|
||||
@@ -0,0 +1,171 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Form implementation generated from reading ui file 'find_dialog.ui'
|
||||
#
|
||||
# Created by: PyQt5 UI code generator 5.15.2
|
||||
#
|
||||
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
|
||||
# run again. Do not edit this file unless you know what you are doing.
|
||||
|
||||
|
||||
from ezdxf.addons.xqt import QtCore, QtGui, QtWidgets
|
||||
|
||||
|
||||
class Ui_FindDialog(object):
|
||||
def setupUi(self, FindDialog):
|
||||
FindDialog.setObjectName("FindDialog")
|
||||
FindDialog.resize(320, 376)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(FindDialog.sizePolicy().hasHeightForWidth())
|
||||
FindDialog.setSizePolicy(sizePolicy)
|
||||
FindDialog.setMinimumSize(QtCore.QSize(320, 376))
|
||||
FindDialog.setMaximumSize(QtCore.QSize(320, 376))
|
||||
FindDialog.setBaseSize(QtCore.QSize(320, 376))
|
||||
self.verticalLayout_5 = QtWidgets.QVBoxLayout(FindDialog)
|
||||
self.verticalLayout_5.setObjectName("verticalLayout_5")
|
||||
self.verticalLayout = QtWidgets.QVBoxLayout()
|
||||
self.verticalLayout.setObjectName("verticalLayout")
|
||||
self.horizontalLayout = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout.setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
|
||||
self.horizontalLayout.setObjectName("horizontalLayout")
|
||||
self.label = QtWidgets.QLabel(FindDialog)
|
||||
self.label.setObjectName("label")
|
||||
self.horizontalLayout.addWidget(self.label)
|
||||
self.find_text_edit = QtWidgets.QLineEdit(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.find_text_edit.sizePolicy().hasHeightForWidth())
|
||||
self.find_text_edit.setSizePolicy(sizePolicy)
|
||||
self.find_text_edit.setMinimumSize(QtCore.QSize(0, 24))
|
||||
self.find_text_edit.setMaximumSize(QtCore.QSize(16777215, 24))
|
||||
self.find_text_edit.setObjectName("find_text_edit")
|
||||
self.horizontalLayout.addWidget(self.find_text_edit)
|
||||
self.verticalLayout.addLayout(self.horizontalLayout)
|
||||
self.groupBox = QtWidgets.QGroupBox(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.groupBox.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox.setSizePolicy(sizePolicy)
|
||||
self.groupBox.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
|
||||
self.groupBox.setFlat(False)
|
||||
self.groupBox.setObjectName("groupBox")
|
||||
self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.groupBox)
|
||||
self.verticalLayout_3.setObjectName("verticalLayout_3")
|
||||
self.whole_words_check_box = QtWidgets.QCheckBox(self.groupBox)
|
||||
self.whole_words_check_box.setObjectName("whole_words_check_box")
|
||||
self.verticalLayout_3.addWidget(self.whole_words_check_box)
|
||||
self.match_case_check_box = QtWidgets.QCheckBox(self.groupBox)
|
||||
self.match_case_check_box.setObjectName("match_case_check_box")
|
||||
self.verticalLayout_3.addWidget(self.match_case_check_box)
|
||||
self.number_tags_check_box = QtWidgets.QCheckBox(self.groupBox)
|
||||
self.number_tags_check_box.setObjectName("number_tags_check_box")
|
||||
self.verticalLayout_3.addWidget(self.number_tags_check_box)
|
||||
self.verticalLayout.addWidget(self.groupBox, 0, QtCore.Qt.AlignTop)
|
||||
self.groupBox_2 = QtWidgets.QGroupBox(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_2.setSizePolicy(sizePolicy)
|
||||
self.groupBox_2.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
|
||||
self.groupBox_2.setObjectName("groupBox_2")
|
||||
self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.groupBox_2)
|
||||
self.verticalLayout_4.setObjectName("verticalLayout_4")
|
||||
self.header_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.header_check_box.setChecked(True)
|
||||
self.header_check_box.setObjectName("header_check_box")
|
||||
self.verticalLayout_4.addWidget(self.header_check_box)
|
||||
self.classes_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.classes_check_box.setObjectName("classes_check_box")
|
||||
self.verticalLayout_4.addWidget(self.classes_check_box)
|
||||
self.tables_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.tables_check_box.setChecked(True)
|
||||
self.tables_check_box.setObjectName("tables_check_box")
|
||||
self.verticalLayout_4.addWidget(self.tables_check_box)
|
||||
self.blocks_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.blocks_check_box.setChecked(True)
|
||||
self.blocks_check_box.setObjectName("blocks_check_box")
|
||||
self.verticalLayout_4.addWidget(self.blocks_check_box)
|
||||
self.entities_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.entities_check_box.setChecked(True)
|
||||
self.entities_check_box.setObjectName("entities_check_box")
|
||||
self.verticalLayout_4.addWidget(self.entities_check_box)
|
||||
self.objects_check_box = QtWidgets.QCheckBox(self.groupBox_2)
|
||||
self.objects_check_box.setChecked(False)
|
||||
self.objects_check_box.setObjectName("objects_check_box")
|
||||
self.verticalLayout_4.addWidget(self.objects_check_box)
|
||||
self.verticalLayout.addWidget(self.groupBox_2, 0, QtCore.Qt.AlignTop)
|
||||
self.verticalLayout_5.addLayout(self.verticalLayout)
|
||||
self.message = QtWidgets.QLabel(FindDialog)
|
||||
self.message.setObjectName("message")
|
||||
self.verticalLayout_5.addWidget(self.message)
|
||||
self.buttons_layout = QtWidgets.QHBoxLayout()
|
||||
self.buttons_layout.setSizeConstraint(QtWidgets.QLayout.SetNoConstraint)
|
||||
self.buttons_layout.setObjectName("buttons_layout")
|
||||
self.find_forward_button = QtWidgets.QPushButton(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.find_forward_button.sizePolicy().hasHeightForWidth())
|
||||
self.find_forward_button.setSizePolicy(sizePolicy)
|
||||
self.find_forward_button.setMinimumSize(QtCore.QSize(0, 0))
|
||||
self.find_forward_button.setMaximumSize(QtCore.QSize(200, 100))
|
||||
self.find_forward_button.setObjectName("find_forward_button")
|
||||
self.buttons_layout.addWidget(self.find_forward_button, 0, QtCore.Qt.AlignBottom)
|
||||
self.find_backwards_button = QtWidgets.QPushButton(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.find_backwards_button.sizePolicy().hasHeightForWidth())
|
||||
self.find_backwards_button.setSizePolicy(sizePolicy)
|
||||
self.find_backwards_button.setMinimumSize(QtCore.QSize(0, 0))
|
||||
self.find_backwards_button.setMaximumSize(QtCore.QSize(200, 100))
|
||||
self.find_backwards_button.setObjectName("find_backwards_button")
|
||||
self.buttons_layout.addWidget(self.find_backwards_button, 0, QtCore.Qt.AlignBottom)
|
||||
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
|
||||
self.buttons_layout.addItem(spacerItem)
|
||||
self.close_button = QtWidgets.QPushButton(FindDialog)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.close_button.sizePolicy().hasHeightForWidth())
|
||||
self.close_button.setSizePolicy(sizePolicy)
|
||||
self.close_button.setMinimumSize(QtCore.QSize(0, 0))
|
||||
self.close_button.setMaximumSize(QtCore.QSize(200, 100))
|
||||
self.close_button.setToolTip("")
|
||||
self.close_button.setObjectName("close_button")
|
||||
self.buttons_layout.addWidget(self.close_button, 0, QtCore.Qt.AlignRight|QtCore.Qt.AlignBottom)
|
||||
self.verticalLayout_5.addLayout(self.buttons_layout)
|
||||
|
||||
self.retranslateUi(FindDialog)
|
||||
QtCore.QMetaObject.connectSlotsByName(FindDialog)
|
||||
|
||||
def retranslateUi(self, FindDialog):
|
||||
_translate = QtCore.QCoreApplication.translate
|
||||
FindDialog.setWindowTitle(_translate("FindDialog", "Find"))
|
||||
self.label.setText(_translate("FindDialog", "Find Text:"))
|
||||
self.groupBox.setTitle(_translate("FindDialog", "Options"))
|
||||
self.whole_words_check_box.setToolTip(_translate("FindDialog", "Search only whole words in normal mode if checked."))
|
||||
self.whole_words_check_box.setText(_translate("FindDialog", "Whole Words"))
|
||||
self.match_case_check_box.setToolTip(_translate("FindDialog", "Case sensitive search in normal mode if checked."))
|
||||
self.match_case_check_box.setText(_translate("FindDialog", "Match Case"))
|
||||
self.number_tags_check_box.setToolTip(_translate("FindDialog", "Ignore numeric DXF tags if checked."))
|
||||
self.number_tags_check_box.setText(_translate("FindDialog", "Search in Numeric Tags"))
|
||||
self.groupBox_2.setToolTip(_translate("FindDialog", "Select sections to search in."))
|
||||
self.groupBox_2.setTitle(_translate("FindDialog", "Search in Sections"))
|
||||
self.header_check_box.setText(_translate("FindDialog", "HEADER"))
|
||||
self.classes_check_box.setText(_translate("FindDialog", "CLASSES"))
|
||||
self.tables_check_box.setText(_translate("FindDialog", "TABLES"))
|
||||
self.blocks_check_box.setText(_translate("FindDialog", "BLOCKS"))
|
||||
self.entities_check_box.setText(_translate("FindDialog", "ENTITIES"))
|
||||
self.objects_check_box.setText(_translate("FindDialog", "OBJECTS"))
|
||||
self.message.setText(_translate("FindDialog", "TextLabel"))
|
||||
self.find_forward_button.setToolTip(_translate("FindDialog", "or press F3"))
|
||||
self.find_forward_button.setText(_translate("FindDialog", "Find &Forward"))
|
||||
self.find_backwards_button.setToolTip(_translate("FindDialog", "or press F4"))
|
||||
self.find_backwards_button.setText(_translate("FindDialog", "Find &Backwards"))
|
||||
self.close_button.setText(_translate("FindDialog", "Close"))
|
||||
@@ -0,0 +1,36 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Union, Iterable, TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
from ezdxf.lldxf import loader
|
||||
from ezdxf.lldxf.types import DXFTag
|
||||
from ezdxf.lldxf.tagger import ascii_tags_loader, binary_tags_loader
|
||||
from ezdxf.lldxf.validator import is_dxf_file, is_binary_dxf_file
|
||||
from ezdxf.filemanagement import dxf_file_info
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.eztypes import SectionDict
|
||||
|
||||
|
||||
def load_section_dict(filename: Union[str, Path]) -> SectionDict:
|
||||
tagger = get_tag_loader(filename)
|
||||
return loader.load_dxf_structure(tagger)
|
||||
|
||||
|
||||
def get_tag_loader(
|
||||
filename: Union[str, Path], errors: str = "ignore"
|
||||
) -> Iterable[DXFTag]:
|
||||
|
||||
filename = str(filename)
|
||||
if is_binary_dxf_file(filename):
|
||||
with open(filename, "rb") as fp:
|
||||
data = fp.read()
|
||||
return binary_tags_loader(data, errors=errors)
|
||||
|
||||
if not is_dxf_file(filename):
|
||||
raise IOError(f"File '{filename}' is not a DXF file.")
|
||||
|
||||
info = dxf_file_info(filename)
|
||||
with open(filename, mode="rt", encoding=info.encoding, errors=errors) as fp:
|
||||
return list(ascii_tags_loader(fp, skip_comments=True))
|
||||
574
.venv/lib/python3.12/site-packages/ezdxf/addons/browser/model.py
Normal file
574
.venv/lib/python3.12/site-packages/ezdxf/addons/browser/model.py
Normal 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()
|
||||
@@ -0,0 +1,117 @@
|
||||
# Autodesk DXF 2018 Reference
|
||||
link_tpl = "https://help.autodesk.com/view/OARX/2018/ENU/?guid={guid}"
|
||||
# Autodesk DXF 2014 Reference
|
||||
# link_tpl = 'https://docs.autodesk.com/ACD/2014/ENU/files/{guid}.htm'
|
||||
main_index_guid = "GUID-235B22E0-A567-4CF6-92D3-38A2306D73F3" # main index
|
||||
|
||||
reference_guids = {
|
||||
"HEADER": "GUID-EA9CDD11-19D1-4EBC-9F56-979ACF679E3C",
|
||||
"CLASSES": "GUID-6160F1F1-2805-4C69-8077-CA1AEB6B1005",
|
||||
"TABLES": "GUID-A9FD9590-C97B-4E41-9F26-BD82C34A4F9F",
|
||||
"BLOCKS": "GUID-1D14A213-5E4D-4EA6-A6B5-8709EB925D01",
|
||||
"ENTITIES": "GUID-7D07C886-FD1D-4A0C-A7AB-B4D21F18E484",
|
||||
"OBJECTS": "GUID-2D71EE99-A6BE-4060-9B43-808CF1E201C6",
|
||||
"THUMBNAILIMAGE": "GUID-792F79DC-0D5D-43B5-AB0E-212E0EDF6BAE",
|
||||
"APPIDS": "GUID-6E3140E9-E560-4C77-904E-480382F0553E",
|
||||
"APPID": "GUID-6E3140E9-E560-4C77-904E-480382F0553E",
|
||||
"BLOCK_RECORDS": "GUID-A1FD1934-7EF5-4D35-A4B0-F8AE54A9A20A",
|
||||
"BLOCK_RECORD": "GUID-A1FD1934-7EF5-4D35-A4B0-F8AE54A9A20A",
|
||||
"DIMSTYLES": "GUID-F2FAD36F-0CE3-4943-9DAD-A9BCD2AE81DA",
|
||||
"DIMSTYLE": "GUID-F2FAD36F-0CE3-4943-9DAD-A9BCD2AE81DA",
|
||||
"LAYERS": "GUID-D94802B0-8BE8-4AC9-8054-17197688AFDB",
|
||||
"LAYER": "GUID-D94802B0-8BE8-4AC9-8054-17197688AFDB",
|
||||
"LINETYPES": "GUID-F57A316C-94A2-416C-8280-191E34B182AC",
|
||||
"LTYPE": "GUID-F57A316C-94A2-416C-8280-191E34B182AC",
|
||||
"STYLES": "GUID-EF68AF7C-13EF-45A1-8175-ED6CE66C8FC9",
|
||||
"STYLE": "GUID-EF68AF7C-13EF-45A1-8175-ED6CE66C8FC9",
|
||||
"UCS": "GUID-1906E8A7-3393-4BF9-BD27-F9AE4352FB8B",
|
||||
"VIEWS": "GUID-CF3094AB-ECA9-43C1-8075-7791AC84F97C",
|
||||
"VIEW": "GUID-CF3094AB-ECA9-43C1-8075-7791AC84F97C",
|
||||
"VIEWPORTS": "GUID-8CE7CC87-27BD-4490-89DA-C21F516415A9",
|
||||
"VPORT": "GUID-8CE7CC87-27BD-4490-89DA-C21F516415A9",
|
||||
"BLOCK": "GUID-66D32572-005A-4E23-8B8B-8726E8C14302",
|
||||
"ENDBLK": "GUID-27F7CC8A-E340-4C7F-A77F-5AF139AD502D",
|
||||
"3DFACE": "GUID-747865D5-51F0-45F2-BEFE-9572DBC5B151",
|
||||
"3DSOLID": "GUID-19AB1C40-0BE0-4F32-BCAB-04B37044A0D3",
|
||||
"ACAD_PROXY_ENTITY": "GUID-89A690F9-E859-4D57-89EA-750F3FB76C6B",
|
||||
"ARC": "GUID-0B14D8F1-0EBA-44BF-9108-57D8CE614BC8",
|
||||
"ATTDEF": "GUID-F0EA099B-6F88-4BCC-BEC7-247BA64838A4",
|
||||
"ATTRIB": "GUID-7DD8B495-C3F8-48CD-A766-14F9D7D0DD9B",
|
||||
"BODY": "GUID-7FB91514-56FF-4487-850E-CF1047999E77",
|
||||
"CIRCLE": "GUID-8663262B-222C-414D-B133-4A8506A27C18",
|
||||
"DIMENSION": "GUID-239A1BDD-7459-4BB9-8DD7-08EC79BF1EB0",
|
||||
"ELLIPSE": "GUID-107CB04F-AD4D-4D2F-8EC9-AC90888063AB",
|
||||
"HATCH": "GUID-C6C71CED-CE0F-4184-82A5-07AD6241F15B",
|
||||
"HELIX": "GUID-76DB3ABF-3C8C-47D1-8AFB-72942D9AE1FF",
|
||||
"IMAGE": "GUID-3A2FF847-BE14-4AC5-9BD4-BD3DCAEF2281",
|
||||
"INSERT": "GUID-28FA4CFB-9D5E-4880-9F11-36C97578252F",
|
||||
"LEADER": "GUID-396B2369-F89F-47D7-8223-8B7FB794F9F3",
|
||||
"LIGHT": "GUID-1A23DB42-6A92-48E9-9EB2-A7856A479930",
|
||||
"LINE": "GUID-FCEF5726-53AE-4C43-B4EA-C84EB8686A66",
|
||||
"LWPOLYLINE": "GUID-748FC305-F3F2-4F74-825A-61F04D757A50",
|
||||
"MESH": "GUID-4B9ADA67-87C8-4673-A579-6E4C76FF7025",
|
||||
"MLINE": "GUID-590E8AE3-C6D9-4641-8485-D7B3693E432C",
|
||||
"MLEADERSTYLE": "GUID-0E489B69-17A4-4439-8505-9DCE032100B4",
|
||||
"MLEADER": "GUID-72D20B8C-0F5E-4993-BEB7-0FCF94F32BE0",
|
||||
"MULTILEADER": "GUID-72D20B8C-0F5E-4993-BEB7-0FCF94F32BE0",
|
||||
"MTEXT": "GUID-5E5DB93B-F8D3-4433-ADF7-E92E250D2BAB",
|
||||
"OLEFRAME": "GUID-4A10EF68-35A3-4961-8B15-1222ECE5E8C6",
|
||||
"OLE2FRAME": "GUID-77747CE6-82C6-4452-97ED-4CEEB38BE960",
|
||||
"POINT": "GUID-9C6AD32D-769D-4213-85A4-CA9CCB5C5317",
|
||||
"POLYLINE": "GUID-ABF6B778-BE20-4B49-9B58-A94E64CEFFF3",
|
||||
"RAY": "GUID-638B9F01-5D86-408E-A2DE-FA5D6ADBD415",
|
||||
"REGION": "GUID-644BF0F0-FD79-4C5E-AD5A-0053FCC5A5A4",
|
||||
"SECTION": "GUID-8B60CBAB-B226-4A5F-ABB1-46FD8AABB928",
|
||||
"SEQEND": "GUID-FD4FAA74-1F6D-45F6-B132-BF0C4BE6CC3B",
|
||||
"SHAPE": "GUID-0988D755-9AAB-4D6C-8E26-EC636F507F2C",
|
||||
"SOLID": "GUID-E0C5F04E-D0C5-48F5-AC09-32733E8848F2",
|
||||
"SPLINE": "GUID-E1F884F8-AA90-4864-A215-3182D47A9C74",
|
||||
"SUN": "GUID-BB191D89-9302-45E4-9904-108AB418FAE1",
|
||||
"SURFACE": "GUID-BB62483A-89C3-47C4-80E5-EA3F08979863",
|
||||
"TABLE": "GUID-D8CCD2F0-18A3-42BB-A64D-539114A07DA0",
|
||||
"TEXT": "GUID-62E5383D-8A14-47B4-BFC4-35824CAE8363",
|
||||
"TOLERANCE": "GUID-ADFCED35-B312-4996-B4C1-61C53757B3FD",
|
||||
"TRACE": "GUID-EA6FBCA8-1AD6-4FB2-B149-770313E93511",
|
||||
"UNDERLAY": "GUID-3EC8FBCC-A85A-4B0B-93CD-C6C785959077",
|
||||
"VERTEX": "GUID-0741E831-599E-4CBF-91E1-8ADBCFD6556D",
|
||||
"VIEWPORT": "GUID-2602B0FB-02E4-4B9A-B03C-B1D904753D34",
|
||||
"WIPEOUT": "GUID-2229F9C4-3C80-4C67-9EDA-45ED684808DC",
|
||||
"XLINE": "GUID-55080553-34B6-40AA-9EE2-3F3A3A2A5C0A",
|
||||
"ACAD_PROXY_OBJECT": "GUID-F59F0EC3-D34D-4C1A-91AC-7FDA569EF016",
|
||||
"ACDBDICTIONARYWDFLT": "GUID-A6605C05-1CF4-42A4-95EC-42190B2424EE",
|
||||
"ACDBPLACEHOLDER": "GUID-3BC75FF1-6139-49F4-AEBB-AE2AB4F437E4",
|
||||
"DATATABLE": "GUID-D09D0650-B926-40DD-A2F2-4FD5BDDFC330",
|
||||
"DICTIONARY": "GUID-40B92C63-26F0-485B-A9C2-B349099B26D0",
|
||||
"DICTIONARYVAR": "GUID-D305303C-F9CE-4714-9C92-607BFDA891B4",
|
||||
"DIMASSOC": "GUID-C0B96256-A911-4B4D-85E6-EB4AF2C91E27",
|
||||
"FIELD": "GUID-51B921F2-16CA-4948-AC75-196198DD1796",
|
||||
"GEODATA": "GUID-104FE0E2-4801-4AC8-B92C-1DDF5AC7AB64",
|
||||
"GROUP": "GUID-5F1372C4-37C8-4056-9303-EE1715F58E67",
|
||||
"IDBUFFER": "GUID-7A243F2B-72D8-4C48-A29A-3F251B86D03F",
|
||||
"IMAGEDEF": "GUID-EFE5319F-A71A-4612-9431-42B6C7C3941F",
|
||||
"IMAGEDEF_REACTOR": "GUID-46C12333-1EDA-4619-B2C9-D7D2607110C8",
|
||||
"LAYER_INDEX": "GUID-17560B05-31B9-44A5-BA92-E92C799398C0",
|
||||
"LAYER_FILTER": "GUID-3B44DCFD-FA96-482B-8468-37B3C5B5F289",
|
||||
"LAYOUT": "GUID-433D25BF-655D-4697-834E-C666EDFD956D",
|
||||
"LIGHTLIST": "GUID-C4E7FFF8-C3ED-43DD-854D-304F87FFCF06",
|
||||
"MATERIAL": "GUID-E540C5BB-E166-44FA-B36C-5C739878B272",
|
||||
"MLINESTYLE": "GUID-3EC12E5B-F5F6-484D-880F-D69EBE186D79",
|
||||
"OBJECT_PTR": "GUID-6D6885E2-281C-410A-92FB-8F6A7F54C9DF",
|
||||
"PLOTSETTINGS": "GUID-1113675E-AB07-4567-801A-310CDE0D56E9",
|
||||
"RASTERVARIABLES": "GUID-DDCC21A4-822A-469B-9954-1E1EC4F6DF82",
|
||||
"SPATIAL_INDEX": "GUID-CD1E44DA-CDBA-4AA7-B08E-C53F71648984",
|
||||
"SPATIAL_FILTER": "GUID-34F179D8-2030-47E4-8D49-F87B6538A05A",
|
||||
"SORTENTSTABLE": "GUID-462F4378-F850-4E89-90F2-3C1880F55779",
|
||||
"SUNSTUDY": "GUID-1C7C073F-4CFD-4939-97D9-7AB0C1E163A3",
|
||||
"TABLESTYLE": "GUID-0DBCA057-9F6C-4DEB-A66F-8A9B3C62FB1A",
|
||||
"UNDERLAYDEFINITION": "GUID-A4FF15D3-F745-4E1F-94D4-1DC3DF297B0F",
|
||||
"VISUALSTYLE": "GUID-8A8BF2C4-FC56-44EC-A8C4-A60CE33A530C",
|
||||
"VBA_PROJECT": "GUID-F247DB75-5C4D-4944-8C20-1567480221F4",
|
||||
"WIPEOUTVARIABLES": "GUID-CD28B95F-483C-4080-82A6-420606F88356",
|
||||
"XRECORD": "GUID-24668FAF-AE03-41AE-AFA4-276C3692827F",
|
||||
}
|
||||
|
||||
|
||||
def get_reference_link(name: str) -> str:
|
||||
guid = reference_guids.get(name, main_index_guid)
|
||||
return link_tpl.format(guid=guid)
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from ezdxf.lldxf.tags import Tags
|
||||
from ezdxf.lldxf.types import (
|
||||
DXFTag,
|
||||
DXFVertex,
|
||||
POINT_CODES,
|
||||
TYPE_TABLE,
|
||||
)
|
||||
|
||||
|
||||
def tag_compiler(tags: Tags) -> Iterable[DXFTag]:
|
||||
"""Special tag compiler for the DXF browser.
|
||||
|
||||
This compiler should never fail and always return printable tags:
|
||||
|
||||
- invalid point coordinates are returned as float("nan")
|
||||
- invalid ints are returned as string
|
||||
- invalid floats are returned as string
|
||||
|
||||
"""
|
||||
|
||||
def to_float(v: str) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return float("NaN")
|
||||
|
||||
count = len(tags)
|
||||
index = 0
|
||||
while index < count:
|
||||
code, value = tags[index]
|
||||
if code in POINT_CODES:
|
||||
try:
|
||||
y_code, y_value = tags[index + 1]
|
||||
except IndexError: # x-coord as last tag
|
||||
yield DXFTag(code, to_float(value))
|
||||
return
|
||||
|
||||
if y_code != code + 10: # not an y-coord?
|
||||
yield DXFTag(code, to_float(value)) # x-coord as single tag
|
||||
index += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
z_code, z_value = tags[index + 2]
|
||||
except IndexError: # no z-coord exist
|
||||
z_code = 0
|
||||
z_value = 0
|
||||
point: Sequence[float]
|
||||
if z_code == code + 20: # is a z-coord?
|
||||
point = (to_float(value), to_float(y_value), to_float(z_value))
|
||||
index += 3
|
||||
else: # a valid 2d point(x, y)
|
||||
point = (to_float(value), to_float(y_value))
|
||||
index += 2
|
||||
yield DXFVertex(code, point)
|
||||
else: # a single tag
|
||||
try:
|
||||
if code == 0:
|
||||
value = value.strip()
|
||||
yield DXFTag(code, TYPE_TABLE.get(code, str)(value))
|
||||
except ValueError:
|
||||
yield DXFTag(code, str(value)) # just as string
|
||||
index += 1
|
||||
|
||||
|
||||
def compile_tags(tags: Tags) -> Tags:
|
||||
return Tags(tag_compiler(tags))
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from ezdxf.addons.xqt import QTableView, QTreeView, QModelIndex
|
||||
from ezdxf.lldxf.tags import Tags
|
||||
|
||||
|
||||
class StructureTree(QTreeView):
|
||||
def set_structure(self, model):
|
||||
self.setModel(model)
|
||||
self.expand(model.index(0, 0, QModelIndex()))
|
||||
self.setHeaderHidden(True)
|
||||
|
||||
def expand_to_entity(self, entity: Tags):
|
||||
model = self.model()
|
||||
index = model.index_of_entity(entity) # type: ignore
|
||||
self.setCurrentIndex(index)
|
||||
|
||||
|
||||
class DXFTagsTable(QTableView):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
col_header = self.horizontalHeader()
|
||||
col_header.setStretchLastSection(True)
|
||||
row_header = self.verticalHeader()
|
||||
row_header.setDefaultSectionSize(24) # default row height in pixels
|
||||
self.setSelectionBehavior(QTableView.SelectRows)
|
||||
|
||||
def first_selected_row(self) -> int:
|
||||
first_row: int = 0
|
||||
selection = self.selectedIndexes()
|
||||
if selection:
|
||||
first_row = selection[0].row()
|
||||
return first_row
|
||||
|
||||
def selected_rows(self) -> list[int]:
|
||||
rows: set[int] = set()
|
||||
selection = self.selectedIndexes()
|
||||
for item in selection:
|
||||
rows.add(item.row())
|
||||
return sorted(rows)
|
||||
766
.venv/lib/python3.12/site-packages/ezdxf/addons/dimlines.py
Normal file
766
.venv/lib/python3.12/site-packages/ezdxf/addons/dimlines.py
Normal 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)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2020-2021, Matthew Broadway
|
||||
# License: MIT License
|
||||
|
||||
from .frontend import Frontend
|
||||
from .properties import Properties, RenderContext, LayerProperties
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,265 @@
|
||||
# Copyright (c) 2020-2023, Matthew Broadway
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod, ABCMeta
|
||||
from typing import Optional, Iterable
|
||||
|
||||
import numpy as np
|
||||
from typing_extensions import TypeAlias
|
||||
import dataclasses
|
||||
|
||||
from ezdxf.addons.drawing.config import Configuration
|
||||
from ezdxf.addons.drawing.properties import Properties, BackendProperties
|
||||
from ezdxf.addons.drawing.type_hints import Color
|
||||
from ezdxf.entities import DXFGraphic
|
||||
from ezdxf.math import Vec2, Matrix44
|
||||
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d, single_paths
|
||||
|
||||
BkPath2d: TypeAlias = NumpyPath2d
|
||||
BkPoints2d: TypeAlias = NumpyPoints2d
|
||||
|
||||
# fmt: off
|
||||
_IMAGE_FLIP_MATRIX = [
|
||||
1.0, 0.0, 0.0, 0.0,
|
||||
0.0, -1.0, 0.0, 0.0,
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
0.0, 999, 0.0, 1.0 # index 13: 999 = image height
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ImageData:
|
||||
"""Image data.
|
||||
|
||||
Attributes:
|
||||
image: an array of RGBA pixels
|
||||
transform: the transformation to apply to the image when drawing
|
||||
(the transform from pixel coordinates to wcs)
|
||||
pixel_boundary_path: boundary path vertices in pixel coordinates, the image
|
||||
coordinate system has an inverted y-axis and the top-left corner is (0, 0)
|
||||
remove_outside: remove image outside the clipping boundary if ``True`` otherwise
|
||||
remove image inside the clipping boundary
|
||||
|
||||
"""
|
||||
|
||||
image: np.ndarray
|
||||
transform: Matrix44
|
||||
pixel_boundary_path: NumpyPoints2d
|
||||
use_clipping_boundary: bool = False
|
||||
remove_outside: bool = True
|
||||
|
||||
def image_size(self) -> tuple[int, int]:
|
||||
"""Returns the image size as tuple (width, height)."""
|
||||
image_height, image_width, *_ = self.image.shape
|
||||
return image_width, image_height
|
||||
|
||||
def flip_matrix(self) -> Matrix44:
|
||||
"""Returns the transformation matrix to align the image coordinate system with
|
||||
the WCS.
|
||||
"""
|
||||
_, image_height = self.image_size()
|
||||
_IMAGE_FLIP_MATRIX[13] = image_height
|
||||
return Matrix44(_IMAGE_FLIP_MATRIX)
|
||||
|
||||
|
||||
class BackendInterface(ABC):
|
||||
"""Public interface for 2D rendering backends."""
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: Configuration) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
|
||||
# gets the full DXF properties information
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def exit_entity(self, entity: DXFGraphic) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def set_background(self, color: Color) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def finalize(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Backend(BackendInterface, metaclass=ABCMeta):
|
||||
def __init__(self) -> None:
|
||||
self.entity_stack: list[tuple[DXFGraphic, Properties]] = []
|
||||
self.config: Configuration = Configuration()
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
self.config = config
|
||||
|
||||
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
|
||||
# gets the full DXF properties information
|
||||
self.entity_stack.append((entity, properties))
|
||||
|
||||
def exit_entity(self, entity: DXFGraphic) -> None:
|
||||
e, p = self.entity_stack.pop()
|
||||
assert e is entity, "entity stack mismatch"
|
||||
|
||||
@property
|
||||
def current_entity(self) -> Optional[DXFGraphic]:
|
||||
"""Obtain the current entity being drawn"""
|
||||
return self.entity_stack[-1][0] if self.entity_stack else None
|
||||
|
||||
@abstractmethod
|
||||
def set_background(self, color: Color) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
"""Draw a real dimensionless point, because not all backends support
|
||||
zero-length lines!
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
"""Fast method to draw a bunch of solid lines with the same properties."""
|
||||
# Must be overridden by the backend to gain a performance benefit.
|
||||
# This is the default implementation to ensure compatibility with
|
||||
# existing backends.
|
||||
for s, e in lines:
|
||||
if e.isclose(s):
|
||||
self.draw_point(s, properties)
|
||||
else:
|
||||
self.draw_line(s, e, properties)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
"""Draw an outline path (connected string of line segments and Bezier
|
||||
curves).
|
||||
|
||||
The :meth:`draw_path` implementation is a fall-back implementation
|
||||
which approximates Bezier curves by flattening as line segments.
|
||||
Backends can override this method if better path drawing functionality
|
||||
is available for that backend.
|
||||
|
||||
"""
|
||||
if len(path):
|
||||
vertices = iter(
|
||||
path.flattening(distance=self.config.max_flattening_distance)
|
||||
)
|
||||
prev = next(vertices)
|
||||
for vertex in vertices:
|
||||
self.draw_line(prev, vertex, properties)
|
||||
prev = vertex
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
"""Draw multiple filled paths (connected string of line segments and
|
||||
Bezier curves).
|
||||
|
||||
The current implementation passes these paths to the backend, all backends
|
||||
included in ezdxf handle holes by the even-odd method. If a backend requires
|
||||
oriented paths (exterior paths in counter-clockwise and holes in clockwise
|
||||
orientation) use the function :func:`oriented_paths` to separate and orient the
|
||||
input paths.
|
||||
|
||||
The default implementation draws all paths as filled polygons.
|
||||
|
||||
Args:
|
||||
paths: sequence of paths
|
||||
properties: HATCH properties
|
||||
|
||||
"""
|
||||
for path in paths:
|
||||
self.draw_filled_polygon(
|
||||
BkPoints2d(
|
||||
path.flattening(distance=self.config.max_flattening_distance)
|
||||
),
|
||||
properties,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
"""Fill a polygon whose outline is defined by the given points.
|
||||
Used to draw entities with simple outlines where :meth:`draw_path` may
|
||||
be an inefficient way to draw such a polygon.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
"""Draw an image with the given pixels."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
"""Clear the canvas. Does not reset the internal state of the backend.
|
||||
Make sure that the previous drawing is finished before clearing.
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def finalize(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def oriented_paths(paths: Iterable[BkPath2d]) -> tuple[list[BkPath2d], list[BkPath2d]]:
|
||||
"""Separate paths into exterior paths and holes. Exterior paths are oriented
|
||||
counter-clockwise, holes are oriented clockwise.
|
||||
"""
|
||||
from ezdxf.path import winding_deconstruction, make_polygon_structure
|
||||
|
||||
polygons = make_polygon_structure(single_paths(paths))
|
||||
external_paths: list[BkPath2d]
|
||||
holes: list[BkPath2d]
|
||||
external_paths, holes = winding_deconstruction(polygons)
|
||||
for p in external_paths:
|
||||
p.counter_clockwise()
|
||||
for p in holes:
|
||||
p.clockwise()
|
||||
return external_paths, holes
|
||||
@@ -0,0 +1,292 @@
|
||||
# Copyright (c) 2021-2024, Matthew Broadway
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
import warnings
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
|
||||
from ezdxf import disassemble
|
||||
from ezdxf.enums import Measurement
|
||||
from .type_hints import Color
|
||||
|
||||
|
||||
class LinePolicy(Enum):
|
||||
"""This enum is used to define how to render linetypes.
|
||||
|
||||
.. note::
|
||||
|
||||
Text and shapes in linetypes are not supported.
|
||||
|
||||
Attributes:
|
||||
SOLID: draw all lines as solid regardless of the linetype style
|
||||
ACCURATE: render styled lines as accurately as possible
|
||||
APPROXIMATE: ignored since v0.18.1 - uses always ACCURATE by default
|
||||
|
||||
"""
|
||||
|
||||
SOLID = auto()
|
||||
APPROXIMATE = auto() # ignored since v0.18.1
|
||||
ACCURATE = auto()
|
||||
|
||||
|
||||
class ProxyGraphicPolicy(Enum):
|
||||
"""The action to take when an entity with a proxy graphic is encountered
|
||||
|
||||
.. note::
|
||||
|
||||
To get proxy graphics support proxy graphics have to be loaded:
|
||||
Set the global option :attr:`ezdxf.options.load_proxy_graphics` to
|
||||
``True``, which is the default value.
|
||||
|
||||
This can not prevent drawing proxy graphic inside of blocks,
|
||||
because this is beyond the domain of the drawing add-on!
|
||||
|
||||
Attributes:
|
||||
IGNORE: do not display proxy graphics (skip_entity will be called instead)
|
||||
SHOW: if the entity cannot be rendered directly (e.g. if not implemented)
|
||||
but a proxy is present: display the proxy
|
||||
PREFER: display proxy graphics even for entities where direct rendering
|
||||
is available
|
||||
"""
|
||||
|
||||
IGNORE = auto()
|
||||
SHOW = auto()
|
||||
PREFER = auto()
|
||||
|
||||
|
||||
class HatchPolicy(Enum):
|
||||
"""The action to take when a HATCH entity is encountered
|
||||
|
||||
Attributes:
|
||||
NORMAL: render pattern and solid fillings
|
||||
IGNORE: do not show HATCH entities at all
|
||||
SHOW_OUTLINE: show only the outline of HATCH entities
|
||||
SHOW_SOLID: show HATCH entities as solid filling regardless of the pattern
|
||||
|
||||
"""
|
||||
|
||||
NORMAL = auto()
|
||||
IGNORE = auto()
|
||||
SHOW_OUTLINE = auto()
|
||||
SHOW_SOLID = auto()
|
||||
SHOW_APPROXIMATE_PATTERN = auto() # ignored since v0.18.1 == NORMAL
|
||||
|
||||
|
||||
class LineweightPolicy(Enum):
|
||||
"""This enum is used to define how to determine the lineweight.
|
||||
|
||||
Attributes:
|
||||
ABSOLUTE: in mm as resolved by the :class:`Frontend` class
|
||||
RELATIVE: lineweight is relative to page size
|
||||
RELATIVE_FIXED: fixed lineweight relative to page size for all strokes
|
||||
|
||||
"""
|
||||
|
||||
ABSOLUTE = auto()
|
||||
# set fixed lineweight for all strokes in absolute mode:
|
||||
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
|
||||
# set Configuration.lineweight_scaling to 0
|
||||
|
||||
# The RELATIVE policy is a backend feature and is not supported by all backends!
|
||||
RELATIVE = auto()
|
||||
RELATIVE_FIXED = auto()
|
||||
|
||||
|
||||
class ColorPolicy(Enum):
|
||||
"""This enum is used to define how to determine the line/fill color.
|
||||
|
||||
Attributes:
|
||||
COLOR: as resolved by the :class:`Frontend` class
|
||||
COLOR_SWAP_BW: as resolved by the :class:`Frontend` class but swaps black and white
|
||||
COLOR_NEGATIVE: invert all colors
|
||||
MONOCHROME: maps all colors to gray scale in range [0%, 100%]
|
||||
MONOCHROME_DARK_BG: maps all colors to gray scale in range [30%, 100%], brightens
|
||||
colors for dark backgrounds
|
||||
MONOCHROME_LIGHT_BG: maps all colors to gray scale in range [0%, 70%], darkens
|
||||
colors for light backgrounds
|
||||
BLACK: maps all colors to black
|
||||
WHITE: maps all colors to white
|
||||
CUSTOM: maps all colors to custom color :attr:`Configuration.custom_fg_color`
|
||||
|
||||
"""
|
||||
|
||||
COLOR = auto()
|
||||
COLOR_SWAP_BW = auto()
|
||||
COLOR_NEGATIVE = auto()
|
||||
MONOCHROME = auto()
|
||||
MONOCHROME_DARK_BG = auto()
|
||||
MONOCHROME_LIGHT_BG = auto()
|
||||
BLACK = auto()
|
||||
WHITE = auto()
|
||||
CUSTOM = auto()
|
||||
|
||||
|
||||
class BackgroundPolicy(Enum):
|
||||
"""This enum is used to define the background color.
|
||||
|
||||
Attributes:
|
||||
DEFAULT: as resolved by the :class:`Frontend` class
|
||||
WHITE: white background
|
||||
BLACK: black background
|
||||
PAPERSPACE: default paperspace background
|
||||
MODELSPACE: default modelspace background
|
||||
OFF: fully transparent background
|
||||
CUSTOM: custom background color by :attr:`Configuration.custom_bg_color`
|
||||
|
||||
"""
|
||||
|
||||
DEFAULT = auto()
|
||||
WHITE = auto()
|
||||
BLACK = auto()
|
||||
PAPERSPACE = auto()
|
||||
MODELSPACE = auto()
|
||||
OFF = auto()
|
||||
CUSTOM = auto()
|
||||
|
||||
|
||||
class TextPolicy(Enum):
|
||||
"""This enum is used to define the text rendering.
|
||||
|
||||
Attributes:
|
||||
FILLING: text is rendered as solid filling (default)
|
||||
OUTLINE: text is rendered as outline paths
|
||||
REPLACE_RECT: replace text by a rectangle
|
||||
REPLACE_FILL: replace text by a filled rectangle
|
||||
IGNORE: ignore text entirely
|
||||
|
||||
"""
|
||||
|
||||
FILLING = auto()
|
||||
OUTLINE = auto()
|
||||
REPLACE_RECT = auto()
|
||||
REPLACE_FILL = auto()
|
||||
IGNORE = auto()
|
||||
|
||||
|
||||
class ImagePolicy(Enum):
|
||||
"""This enum is used to define the image rendering.
|
||||
|
||||
Attributes:
|
||||
DISPLAY: display images as they would appear in a regular CAD application
|
||||
RECT: display images as rectangles
|
||||
MISSING: images are always rendered as-if they are missing (rectangle + path text)
|
||||
PROXY: images are rendered using their proxy representations (rectangle)
|
||||
IGNORE: ignore images entirely
|
||||
|
||||
"""
|
||||
|
||||
DISPLAY = auto()
|
||||
RECT = auto()
|
||||
MISSING = auto()
|
||||
PROXY = auto()
|
||||
IGNORE = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Configuration:
|
||||
"""Configuration options for the :mod:`drawing` add-on.
|
||||
|
||||
Attributes:
|
||||
pdsize: the size to draw POINT entities (in drawing units)
|
||||
set to None to use the $PDSIZE value from the dxf document header
|
||||
|
||||
======= ====================================================
|
||||
0 5% of draw area height
|
||||
<0 Specifies a percentage of the viewport size
|
||||
>0 Specifies an absolute size
|
||||
None use the $PDMODE value from the dxf document header
|
||||
======= ====================================================
|
||||
|
||||
pdmode: point styling mode (see POINT documentation)
|
||||
|
||||
see :class:`~ezdxf.entities.Point` class documentation
|
||||
|
||||
measurement: whether to use metric or imperial units as enum :class:`ezdxf.enums.Measurement`
|
||||
|
||||
======= ======================================================
|
||||
0 use imperial units (in, ft, yd, ...)
|
||||
1 use metric units (ISO meters)
|
||||
None use the $MEASUREMENT value from the dxf document header
|
||||
======= ======================================================
|
||||
|
||||
show_defpoints: whether to show or filter out POINT entities on the defpoints layer
|
||||
proxy_graphic_policy: the action to take when a proxy graphic is encountered
|
||||
line_policy: the method to use when drawing styled lines (eg dashed,
|
||||
dotted etc)
|
||||
hatch_policy: the method to use when drawing HATCH entities
|
||||
infinite_line_length: the length to use when drawing infinite lines
|
||||
lineweight_scaling:
|
||||
multiplies every lineweight by this factor; set this factor to 0.0 for a
|
||||
constant minimum line width defined by the :attr:`min_lineweight` setting
|
||||
for all lineweights;
|
||||
the correct DXF lineweight often looks too thick in SVG, so setting a
|
||||
factor < 1 can improve the visual appearance
|
||||
min_lineweight: the minimum line width in 1/300 inch; set to ``None`` for
|
||||
let the backend choose.
|
||||
min_dash_length: the minimum length for a dash when drawing a styled line
|
||||
(default value is arbitrary)
|
||||
max_flattening_distance: Max flattening distance in drawing units
|
||||
see Path.flattening documentation.
|
||||
The backend implementation should calculate an appropriate value,
|
||||
like 1 screen- or paper pixel on the output medium, but converted
|
||||
into drawing units. Sets Path() approximation accuracy
|
||||
circle_approximation_count: Approximate a full circle by `n` segments, arcs
|
||||
have proportional less segments. Only used for approximation of arcs
|
||||
in banded polylines.
|
||||
hatching_timeout: hatching timeout for a single entity, very dense
|
||||
hatching patterns can cause a very long execution time, the default
|
||||
timeout for a single entity is 30 seconds.
|
||||
min_hatch_line_distance: minimum hatch line distance to render, narrower pattern
|
||||
lines are rendered as solid filling
|
||||
color_policy:
|
||||
custom_fg_color: Used for :class:`ColorPolicy.custom` policy, custom foreground
|
||||
color as "#RRGGBBAA" color string (RGB+alpha)
|
||||
background_policy:
|
||||
custom_bg_color: Used for :class:`BackgroundPolicy.custom` policy, custom
|
||||
background color as "#RRGGBBAA" color string (RGB+alpha)
|
||||
lineweight_policy:
|
||||
text_policy:
|
||||
image_policy: the method for drawing IMAGE entities
|
||||
|
||||
"""
|
||||
|
||||
pdsize: Optional[int] = None # use $PDSIZE from HEADER section
|
||||
pdmode: Optional[int] = None # use $PDMODE from HEADER section
|
||||
measurement: Optional[Measurement] = None
|
||||
show_defpoints: bool = False
|
||||
proxy_graphic_policy: ProxyGraphicPolicy = ProxyGraphicPolicy.SHOW
|
||||
line_policy: LinePolicy = LinePolicy.ACCURATE
|
||||
hatch_policy: HatchPolicy = HatchPolicy.NORMAL
|
||||
infinite_line_length: float = 20
|
||||
lineweight_scaling: float = 1.0
|
||||
min_lineweight: Optional[float] = None
|
||||
min_dash_length: float = 0.1
|
||||
max_flattening_distance: float = disassemble.Primitive.max_flattening_distance
|
||||
circle_approximation_count: int = 128
|
||||
hatching_timeout: float = 30.0
|
||||
# Keep value in sync with ezdxf.render.hatching.MIN_HATCH_LINE_DISTANCE
|
||||
min_hatch_line_distance: float = 1e-4
|
||||
color_policy: ColorPolicy = ColorPolicy.COLOR
|
||||
custom_fg_color: Color = "#000000"
|
||||
background_policy: BackgroundPolicy = BackgroundPolicy.DEFAULT
|
||||
custom_bg_color: Color = "#ffffff"
|
||||
lineweight_policy: LineweightPolicy = LineweightPolicy.ABSOLUTE
|
||||
text_policy: TextPolicy = TextPolicy.FILLING
|
||||
image_policy: ImagePolicy = ImagePolicy.DISPLAY
|
||||
|
||||
@staticmethod
|
||||
def defaults() -> Configuration:
|
||||
warnings.warn(
|
||||
"use Configuration() instead of Configuration.defaults()",
|
||||
DeprecationWarning,
|
||||
)
|
||||
return Configuration()
|
||||
|
||||
def with_changes(self, **kwargs) -> Configuration:
|
||||
"""Returns a new frozen :class:`Configuration` object with modified values."""
|
||||
params = dataclasses.asdict(self)
|
||||
for k, v in kwargs.items():
|
||||
params[k] = v
|
||||
return Configuration(**params)
|
||||
@@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2021-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable
|
||||
|
||||
from ezdxf.math import Vec2
|
||||
from .properties import BackendProperties
|
||||
from .backend import Backend, BkPath2d, BkPoints2d, ImageData
|
||||
from .config import Configuration
|
||||
|
||||
|
||||
class BasicBackend(Backend):
|
||||
"""The basic backend has no draw_path() support and approximates all curves
|
||||
by lines.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.collector = []
|
||||
self.configure(Configuration())
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
self.collector.append(("point", pos, properties))
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
self.collector.append(("line", start, end, properties))
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
self.collector.append(("filled_polygon", points, properties))
|
||||
|
||||
def draw_image(
|
||||
self, image_data: ImageData, properties: BackendProperties
|
||||
) -> None:
|
||||
self.collector.append(("image", image_data, properties))
|
||||
|
||||
def set_background(self, color: str) -> None:
|
||||
self.collector.append(("bgcolor", color))
|
||||
|
||||
def clear(self) -> None:
|
||||
self.collector = []
|
||||
|
||||
|
||||
class PathBackend(BasicBackend):
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
|
||||
self.collector.append(("path", path, properties))
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
self.collector.append(("filled_path", tuple(paths), properties))
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright (c) 2020-2021, Matthew Broadway
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
|
||||
from ezdxf.addons.drawing.backend import BackendInterface
|
||||
from ezdxf.addons.drawing.type_hints import Color
|
||||
from ezdxf.math import Vec3
|
||||
|
||||
|
||||
def draw_rect(points: list[Vec3], color: Color, out: BackendInterface):
|
||||
from ezdxf.addons.drawing.properties import BackendProperties
|
||||
|
||||
props = BackendProperties(color=color)
|
||||
for a, b in zip(points, points[1:]):
|
||||
out.draw_line(a, b, props)
|
||||
223
.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/dxf.py
Normal file
223
.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/dxf.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
1097
.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/frontend.py
Normal file
1097
.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/frontend.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) 2021-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Iterator
|
||||
from ezdxf.entities import DXFGraphic, DXFEntity
|
||||
from ezdxf.lldxf import const
|
||||
from ezdxf.lldxf.tagwriter import AbstractTagWriter
|
||||
from ezdxf.protocols import SupportsVirtualEntities
|
||||
from ezdxf.entities.copy import default_copy, CopyNotSupported
|
||||
|
||||
|
||||
class DXFGraphicProxy(DXFGraphic):
|
||||
"""DO NOT USE THIS WRAPPER AS REAL DXF ENTITY OUTSIDE THE DRAWING ADD-ON!"""
|
||||
|
||||
def __init__(self, entity: DXFEntity):
|
||||
super().__init__()
|
||||
self.entity = entity
|
||||
self.dxf = self._setup_dxf_namespace(entity)
|
||||
|
||||
def _setup_dxf_namespace(self, entity):
|
||||
# copy DXF namespace - modifications do not effect the wrapped entity
|
||||
dxf = entity.dxf.copy(self)
|
||||
# setup mandatory DXF attributes without default values like layer:
|
||||
for k, v in self.DEFAULT_ATTRIBS.items():
|
||||
if not dxf.hasattr(k):
|
||||
dxf.set(k, v)
|
||||
return dxf
|
||||
|
||||
def dxftype(self) -> str:
|
||||
return self.entity.dxftype()
|
||||
|
||||
def __virtual_entities__(self) -> Iterator[DXFGraphic]:
|
||||
"""Implements the SupportsVirtualEntities protocol."""
|
||||
if isinstance(self.entity, SupportsVirtualEntities):
|
||||
return self.entity.__virtual_entities__()
|
||||
if hasattr(self.entity, "virtual_entities"):
|
||||
return self.entity.virtual_entities()
|
||||
return iter([])
|
||||
|
||||
def virtual_entities(self) -> Iterable[DXFGraphic]:
|
||||
return self.__virtual_entities__()
|
||||
|
||||
def copy(self, copy_strategy=default_copy) -> DXFGraphicProxy:
|
||||
raise CopyNotSupported(f"Copying of DXFGraphicProxy() not supported.")
|
||||
|
||||
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
|
||||
# prevent dxf export
|
||||
return False
|
||||
549
.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/hpgl2.py
Normal file
549
.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/hpgl2.py
Normal 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)
|
||||
590
.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/json.py
Normal file
590
.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/json.py
Normal 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
|
||||
@@ -0,0 +1,561 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import NamedTuple, TYPE_CHECKING
|
||||
from typing_extensions import Self
|
||||
|
||||
import math
|
||||
import enum
|
||||
import dataclasses
|
||||
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.layouts.layout import Layout as DXFLayout
|
||||
|
||||
|
||||
class Units(enum.IntEnum):
|
||||
"""Page units as enum.
|
||||
|
||||
Attributes:
|
||||
inch: 25.4 mm
|
||||
px: 1/96 inch
|
||||
pt: 1/72 inch
|
||||
mm:
|
||||
cm:
|
||||
|
||||
"""
|
||||
|
||||
# equivalent to ezdxf.units if possible
|
||||
inch = 1
|
||||
px = 2 # no equivalent DXF unit
|
||||
pt = 3 # no equivalent DXF unit
|
||||
mm = 4
|
||||
cm = 5
|
||||
|
||||
|
||||
# all page sizes in landscape orientation
|
||||
PAGE_SIZES = {
|
||||
"ISO A0": (1189, 841, Units.mm),
|
||||
"ISO A1": (841, 594, Units.mm),
|
||||
"ISO A2": (594, 420, Units.mm),
|
||||
"ISO A3": (420, 297, Units.mm),
|
||||
"ISO A4": (297, 210, Units.mm),
|
||||
"ANSI A": (11, 8.5, Units.inch),
|
||||
"ANSI B": (17, 11, Units.inch),
|
||||
"ANSI C": (22, 17, Units.inch),
|
||||
"ANSI D": (34, 22, Units.inch),
|
||||
"ANSI E": (44, 34, Units.inch),
|
||||
"ARCH C": (24, 18, Units.inch),
|
||||
"ARCH D": (36, 24, Units.inch),
|
||||
"ARCH E": (48, 36, Units.inch),
|
||||
"ARCH E1": (42, 30, Units.inch),
|
||||
"Letter": (11, 8.5, Units.inch),
|
||||
"Legal": (14, 8.5, Units.inch),
|
||||
}
|
||||
|
||||
|
||||
UNITS_TO_MM = {
|
||||
Units.mm: 1.0,
|
||||
Units.cm: 10.0,
|
||||
Units.inch: 25.4,
|
||||
Units.px: 25.4 / 96.0,
|
||||
Units.pt: 25.4 / 72.0,
|
||||
}
|
||||
|
||||
|
||||
class PageAlignment(enum.IntEnum):
|
||||
"""Page alignment of content as enum.
|
||||
|
||||
Attributes:
|
||||
TOP_LEFT:
|
||||
TOP_CENTER:
|
||||
TOP_RIGHT:
|
||||
MIDDLE_LEFT:
|
||||
MIDDLE_CENTER:
|
||||
MIDDLE_RIGHT:
|
||||
BOTTOM_LEFT:
|
||||
BOTTOM_CENTER:
|
||||
BOTTOM_RIGHT:
|
||||
|
||||
"""
|
||||
|
||||
TOP_LEFT = 1
|
||||
TOP_CENTER = 2
|
||||
TOP_RIGHT = 3
|
||||
MIDDLE_LEFT = 4
|
||||
MIDDLE_CENTER = 5
|
||||
MIDDLE_RIGHT = 6
|
||||
BOTTOM_LEFT = 7
|
||||
BOTTOM_CENTER = 8
|
||||
BOTTOM_RIGHT = 9
|
||||
|
||||
|
||||
class Margins(NamedTuple):
|
||||
"""Page margins definition class
|
||||
|
||||
Attributes:
|
||||
top:
|
||||
left:
|
||||
bottom:
|
||||
right:
|
||||
|
||||
"""
|
||||
|
||||
top: float
|
||||
right: float
|
||||
bottom: float
|
||||
left: float
|
||||
|
||||
@classmethod
|
||||
def all(cls, margin: float) -> Self:
|
||||
"""Returns a page margins definition class with four equal margins."""
|
||||
return cls(margin, margin, margin, margin)
|
||||
|
||||
@classmethod
|
||||
def all2(cls, top_bottom: float, left_right: float) -> Self:
|
||||
"""Returns a page margins definition class with equal top-bottom and
|
||||
left-right margins.
|
||||
"""
|
||||
return cls(top_bottom, left_right, top_bottom, left_right)
|
||||
|
||||
# noinspection PyArgumentList
|
||||
def scale(self, factor: float) -> Self:
|
||||
return self.__class__(
|
||||
self.top * factor,
|
||||
self.right * factor,
|
||||
self.bottom * factor,
|
||||
self.left * factor,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Page:
|
||||
"""Page definition class
|
||||
|
||||
Attributes:
|
||||
|
||||
width: page width, 0 for auto-detect
|
||||
height: page height, 0 for auto-detect
|
||||
units: page units as enum :class:`Units`
|
||||
margins: page margins in page units
|
||||
max_width: limit width for auto-detection, 0 for unlimited
|
||||
max_height: limit height for auto-detection, 0 for unlimited
|
||||
|
||||
"""
|
||||
|
||||
width: float
|
||||
height: float
|
||||
units: Units = Units.mm
|
||||
margins: Margins = Margins.all(0)
|
||||
max_width: float = 0.0
|
||||
max_height: float = 0.0
|
||||
|
||||
def __post_init__(self):
|
||||
assert isinstance(self.units, Units), "units require type <Units>"
|
||||
assert isinstance(self.margins, Margins), "margins require type <Margins>"
|
||||
|
||||
@property
|
||||
def to_mm_factor(self) -> float:
|
||||
return UNITS_TO_MM[self.units]
|
||||
|
||||
@property
|
||||
def width_in_mm(self) -> float:
|
||||
"""Returns the page width in mm."""
|
||||
return round(self.width * self.to_mm_factor, 1)
|
||||
|
||||
@property
|
||||
def max_width_in_mm(self) -> float:
|
||||
"""Returns max page width in mm."""
|
||||
return round(self.max_width * self.to_mm_factor, 1)
|
||||
|
||||
@property
|
||||
def height_in_mm(self) -> float:
|
||||
"""Returns the page height in mm."""
|
||||
return round(self.height * self.to_mm_factor, 1)
|
||||
|
||||
@property
|
||||
def max_height_in_mm(self) -> float:
|
||||
"""Returns max page height in mm."""
|
||||
return round(self.max_height * self.to_mm_factor, 1)
|
||||
|
||||
@property
|
||||
def margins_in_mm(self) -> Margins:
|
||||
"""Returns the page margins in mm."""
|
||||
return self.margins.scale(self.to_mm_factor)
|
||||
|
||||
@property
|
||||
def is_landscape(self) -> bool:
|
||||
"""Returns ``True`` if the page has landscape orientation."""
|
||||
return self.width > self.height
|
||||
|
||||
@property
|
||||
def is_portrait(self) -> bool:
|
||||
"""Returns ``True`` if the page has portrait orientation. (square is portrait)"""
|
||||
return self.width <= self.height
|
||||
|
||||
def to_landscape(self) -> None:
|
||||
"""Converts the page to landscape orientation."""
|
||||
if self.is_portrait:
|
||||
self.width, self.height = self.height, self.width
|
||||
|
||||
def to_portrait(self) -> None:
|
||||
"""Converts the page to portrait orientation."""
|
||||
if self.is_landscape:
|
||||
self.width, self.height = self.height, self.width
|
||||
|
||||
def get_margin_rect(self, top_origin=True) -> tuple[Vec2, Vec2]:
|
||||
"""Returns the bottom-left and the top-right corner of the page margins in mm.
|
||||
The origin (0, 0) is the top-left corner of the page if `top_origin` is
|
||||
``True`` or in the bottom-left corner otherwise.
|
||||
"""
|
||||
margins = self.margins_in_mm
|
||||
right_margin = self.width_in_mm - margins.right
|
||||
page_height = self.height_in_mm
|
||||
if top_origin:
|
||||
bottom_left = Vec2(margins.left, margins.top)
|
||||
top_right = Vec2(right_margin, page_height - margins.bottom)
|
||||
else: # bottom origin
|
||||
bottom_left = Vec2(margins.left, margins.bottom)
|
||||
top_right = Vec2(right_margin, page_height - margins.top)
|
||||
return bottom_left, top_right
|
||||
|
||||
@classmethod
|
||||
def from_dxf_layout(cls, layout: DXFLayout) -> Self:
|
||||
"""Returns the :class:`Page` based on the DXF attributes stored in the LAYOUT
|
||||
entity. The modelspace layout often **doesn't** have usable page settings!
|
||||
|
||||
Args:
|
||||
layout: any paperspace layout or the modelspace layout
|
||||
|
||||
"""
|
||||
# all layout measurements in mm
|
||||
width = round(layout.dxf.paper_width, 1)
|
||||
height = round(layout.dxf.paper_height, 1)
|
||||
top = round(layout.dxf.top_margin, 1)
|
||||
right = round(layout.dxf.right_margin, 1)
|
||||
bottom = round(layout.dxf.bottom_margin, 1)
|
||||
left = round(layout.dxf.left_margin, 1)
|
||||
|
||||
rotation = layout.dxf.plot_rotation
|
||||
if rotation == 1: # 90 degrees
|
||||
return cls(
|
||||
height,
|
||||
width,
|
||||
Units.mm,
|
||||
margins=Margins(top=right, right=bottom, bottom=left, left=top),
|
||||
)
|
||||
elif rotation == 2: # 180 degrees
|
||||
return cls(
|
||||
width,
|
||||
height,
|
||||
Units.mm,
|
||||
margins=Margins(top=bottom, right=left, bottom=top, left=right),
|
||||
)
|
||||
elif rotation == 3: # 270 degrees
|
||||
return cls(
|
||||
height,
|
||||
width,
|
||||
Units.mm,
|
||||
margins=Margins(top=left, right=top, bottom=right, left=bottom),
|
||||
)
|
||||
return cls( # 0 degrees
|
||||
width,
|
||||
height,
|
||||
Units.mm,
|
||||
margins=Margins(top=top, right=right, bottom=bottom, left=left),
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Settings:
|
||||
"""The Layout settings.
|
||||
|
||||
Attributes:
|
||||
content_rotation: Rotate content about 0, 90, 180 or 270 degrees
|
||||
fit_page: Scale content to fit the page.
|
||||
page_alignment: Supported by backends that use the :class:`Page` class to define
|
||||
the size of the output media, default alignment is :attr:`PageAlignment.MIDDLE_CENTER`
|
||||
crop_at_margins: crops the content at the page margins if ``True``, when
|
||||
supported by the backend, default is ``False``
|
||||
scale: Factor to scale the DXF units of model- or paperspace, to represent 1mm
|
||||
in the rendered output drawing. Only uniform scaling is supported.
|
||||
|
||||
e.g. scale 1:100 and DXF units are meters, 1m = 1000mm corresponds 10mm in
|
||||
the output drawing = 10 / 1000 = 0.01;
|
||||
|
||||
e.g. scale 1:1; DXF units are mm = 1 / 1 = 1.0 the default value
|
||||
|
||||
The value is ignored if the page size is defined and the content fits the page and
|
||||
the value is also used to determine missing page sizes (width or height).
|
||||
max_stroke_width: Used for :class:`LineweightPolicy.RELATIVE` policy,
|
||||
:attr:`max_stroke_width` is defined as percentage of the content extents,
|
||||
e.g. 0.001 is 0.1% of max(page-width, page-height)
|
||||
min_stroke_width: Used for :class:`LineweightPolicy.RELATIVE` policy,
|
||||
:attr:`min_stroke_width` is defined as percentage of :attr:`max_stroke_width`,
|
||||
e.g. 0.05 is 5% of :attr:`max_stroke_width`
|
||||
fixed_stroke_width: Used for :class:`LineweightPolicy.RELATIVE_FIXED` policy,
|
||||
:attr:`fixed_stroke_width` is defined as percentage of :attr:`max_stroke_width`,
|
||||
e.g. 0.15 is 15% of :attr:`max_stroke_width`
|
||||
output_coordinate_space: expert feature to map the DXF coordinates to the
|
||||
output coordinate system [0, output_coordinate_space]
|
||||
output_layers: For supported backends, separate the entities into 'layers' in the output
|
||||
|
||||
"""
|
||||
|
||||
content_rotation: int = 0
|
||||
fit_page: bool = True
|
||||
scale: float = 1.0
|
||||
page_alignment: PageAlignment = PageAlignment.MIDDLE_CENTER
|
||||
crop_at_margins: bool = False
|
||||
# for LineweightPolicy.RELATIVE
|
||||
# max_stroke_width is defined as percentage of the content extents
|
||||
max_stroke_width: float = 0.001 # 0.1% of max(width, height) in viewBox coords
|
||||
# min_stroke_width is defined as percentage of max_stroke_width
|
||||
min_stroke_width: float = 0.05 # 5% of max_stroke_width
|
||||
# StrokeWidthPolicy.fixed_1
|
||||
# fixed_stroke_width is defined as percentage of max_stroke_width
|
||||
fixed_stroke_width: float = 0.15 # 15% of max_stroke_width
|
||||
# PDF, HPGL expect the coordinates in the first quadrant and SVG has an inverted
|
||||
# y-axis, so transformation from DXF to the output coordinate system is required.
|
||||
# The output_coordinate_space defines the space into which the DXF coordinates are
|
||||
# mapped, the range is [0, output_coordinate_space] for the larger page
|
||||
# dimension - aspect ratio is always preserved - these are CAD drawings!
|
||||
# The SVGBackend uses this feature to map all coordinates to integer values:
|
||||
output_coordinate_space: float = 1_000_000 # e.g. for SVGBackend
|
||||
output_layers: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.content_rotation not in (0, 90, 180, 270):
|
||||
raise ValueError(
|
||||
f"invalid content rotation {self.content_rotation}, "
|
||||
f"expected: 0, 90, 180, 270"
|
||||
)
|
||||
|
||||
def page_output_scale_factor(self, page: Page) -> float:
|
||||
"""Returns the scaling factor to map page coordinates in mm to output space
|
||||
coordinates.
|
||||
"""
|
||||
try:
|
||||
return self.output_coordinate_space / max(
|
||||
page.width_in_mm, page.height_in_mm
|
||||
)
|
||||
except ZeroDivisionError:
|
||||
return 1.0
|
||||
|
||||
|
||||
class Layout:
|
||||
def __init__(self, render_box: BoundingBox2d, flip_y=False) -> None:
|
||||
super().__init__()
|
||||
self.flip_y: float = -1.0 if flip_y else 1.0
|
||||
self.render_box = render_box
|
||||
|
||||
def get_rotation(self, settings: Settings) -> int:
|
||||
if settings.content_rotation not in (0, 90, 180, 270):
|
||||
raise ValueError("content rotation must be 0, 90, 180 or 270 degrees")
|
||||
rotation = settings.content_rotation
|
||||
if self.flip_y == -1.0:
|
||||
if rotation == 90:
|
||||
rotation = 270
|
||||
elif rotation == 270:
|
||||
rotation = 90
|
||||
return rotation
|
||||
|
||||
def get_content_size(self, rotation: int) -> Vec2:
|
||||
content_size = self.render_box.size
|
||||
if rotation in (90, 270):
|
||||
# swap x, y to apply rotation to content_size
|
||||
content_size = Vec2(content_size.y, content_size.x)
|
||||
return content_size
|
||||
|
||||
def get_final_page(self, page: Page, settings: Settings) -> Page:
|
||||
rotation = self.get_rotation(settings)
|
||||
content_size = self.get_content_size(rotation)
|
||||
return final_page_size(content_size, page, settings)
|
||||
|
||||
def get_placement_matrix(
|
||||
self, page: Page, settings=Settings(), top_origin=True
|
||||
) -> Matrix44:
|
||||
# Argument `page` has to be the resolved final page size!
|
||||
rotation = self.get_rotation(settings)
|
||||
|
||||
content_size = self.get_content_size(rotation)
|
||||
content_size_mm = content_size * settings.scale
|
||||
if settings.fit_page:
|
||||
content_size_mm *= fit_to_page(content_size_mm, page)
|
||||
try:
|
||||
scale_dxf_to_mm = content_size_mm.x / content_size.x
|
||||
except ZeroDivisionError:
|
||||
scale_dxf_to_mm = 1.0
|
||||
# map output coordinates to range [0, output_coordinate_space]
|
||||
scale_mm_to_output_space = settings.page_output_scale_factor(page)
|
||||
scale = scale_dxf_to_mm * scale_mm_to_output_space
|
||||
m = placement_matrix(
|
||||
self.render_box,
|
||||
sx=scale,
|
||||
sy=scale * self.flip_y,
|
||||
rotation=rotation,
|
||||
page=page,
|
||||
output_coordinate_space=settings.output_coordinate_space,
|
||||
page_alignment=settings.page_alignment,
|
||||
top_origin=top_origin,
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
def final_page_size(content_size: Vec2, page: Page, settings: Settings) -> Page:
|
||||
scale = settings.scale
|
||||
width = page.width_in_mm
|
||||
height = page.height_in_mm
|
||||
margins = page.margins_in_mm
|
||||
if width == 0.0:
|
||||
width = scale * content_size.x + margins.left + margins.right
|
||||
if height == 0.0:
|
||||
height = scale * content_size.y + margins.top + margins.bottom
|
||||
|
||||
width, height = limit_page_size(
|
||||
width, height, page.max_width_in_mm, page.max_height_in_mm
|
||||
)
|
||||
return Page(round(width, 1), round(height, 1), Units.mm, margins)
|
||||
|
||||
|
||||
def limit_page_size(
|
||||
width: float, height: float, max_width: float, max_height: float
|
||||
) -> tuple[float, float]:
|
||||
try:
|
||||
ar = width / height
|
||||
except ZeroDivisionError:
|
||||
return width, height
|
||||
if max_height:
|
||||
height = min(max_height, height)
|
||||
width = height * ar
|
||||
if max_width and width > max_width:
|
||||
width = min(max_width, width)
|
||||
height = width / ar
|
||||
return width, height
|
||||
|
||||
|
||||
def fit_to_page(content_size_mm: Vec2, page: Page) -> float:
|
||||
margins = page.margins_in_mm
|
||||
try:
|
||||
sx = (page.width_in_mm - margins.left - margins.right) / content_size_mm.x
|
||||
sy = (page.height_in_mm - margins.top - margins.bottom) / content_size_mm.y
|
||||
except ZeroDivisionError:
|
||||
return 1.0
|
||||
return min(sx, sy)
|
||||
|
||||
|
||||
def placement_matrix(
|
||||
bbox: BoundingBox2d,
|
||||
sx: float,
|
||||
sy: float,
|
||||
rotation: float,
|
||||
page: Page,
|
||||
output_coordinate_space: float,
|
||||
page_alignment: PageAlignment = PageAlignment.MIDDLE_CENTER,
|
||||
# top_origin True: page origin (0, 0) in top-left corner, +y axis pointing down
|
||||
# top_origin False: page origin (0, 0) in bottom-left corner, +y axis pointing up
|
||||
top_origin=True,
|
||||
) -> Matrix44:
|
||||
"""Returns a matrix to place the bbox in the first quadrant of the coordinate
|
||||
system (+x, +y).
|
||||
"""
|
||||
try:
|
||||
scale_mm_to_vb = output_coordinate_space / max(
|
||||
page.width_in_mm, page.height_in_mm
|
||||
)
|
||||
except ZeroDivisionError:
|
||||
scale_mm_to_vb = 1.0
|
||||
margins = page.margins_in_mm
|
||||
|
||||
# create scaling and rotation matrix:
|
||||
if abs(sx) < 1e-9:
|
||||
sx = 1.0
|
||||
if abs(sy) < 1e-9:
|
||||
sy = 1.0
|
||||
m = Matrix44.scale(sx, sy, 1.0)
|
||||
if rotation:
|
||||
m @= Matrix44.z_rotate(math.radians(rotation))
|
||||
|
||||
# calc bounding box of the final output canvas:
|
||||
corners = m.transform_vertices(bbox.rect_vertices())
|
||||
canvas = BoundingBox2d(corners)
|
||||
|
||||
# shift content to first quadrant +x/+y
|
||||
tx, ty = canvas.extmin
|
||||
|
||||
# align content within margins
|
||||
view_box_content_x = (
|
||||
page.width_in_mm - margins.left - margins.right
|
||||
) * scale_mm_to_vb
|
||||
view_box_content_y = (
|
||||
page.height_in_mm - margins.top - margins.bottom
|
||||
) * scale_mm_to_vb
|
||||
dx = view_box_content_x - canvas.size.x
|
||||
dy = view_box_content_y - canvas.size.y
|
||||
offset_x = margins.left * scale_mm_to_vb # left
|
||||
if top_origin:
|
||||
offset_y = margins.top * scale_mm_to_vb
|
||||
else:
|
||||
offset_y = margins.bottom * scale_mm_to_vb
|
||||
|
||||
if is_center_aligned(page_alignment):
|
||||
offset_x += dx / 2
|
||||
elif is_right_aligned(page_alignment):
|
||||
offset_x += dx
|
||||
if is_middle_aligned(page_alignment):
|
||||
offset_y += dy / 2
|
||||
elif is_bottom_aligned(page_alignment):
|
||||
if top_origin:
|
||||
offset_y += dy
|
||||
else: # top aligned
|
||||
if not top_origin:
|
||||
offset_y += dy
|
||||
return m @ Matrix44.translate(-tx + offset_x, -ty + offset_y, 0)
|
||||
|
||||
|
||||
def is_left_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.TOP_LEFT,
|
||||
PageAlignment.MIDDLE_LEFT,
|
||||
PageAlignment.BOTTOM_LEFT,
|
||||
)
|
||||
|
||||
|
||||
def is_center_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.TOP_CENTER,
|
||||
PageAlignment.MIDDLE_CENTER,
|
||||
PageAlignment.BOTTOM_CENTER,
|
||||
)
|
||||
|
||||
|
||||
def is_right_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.TOP_RIGHT,
|
||||
PageAlignment.MIDDLE_RIGHT,
|
||||
PageAlignment.BOTTOM_RIGHT,
|
||||
)
|
||||
|
||||
|
||||
def is_top_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.TOP_LEFT,
|
||||
PageAlignment.TOP_CENTER,
|
||||
PageAlignment.TOP_RIGHT,
|
||||
)
|
||||
|
||||
|
||||
def is_middle_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.MIDDLE_LEFT,
|
||||
PageAlignment.MIDDLE_CENTER,
|
||||
PageAlignment.MIDDLE_RIGHT,
|
||||
)
|
||||
|
||||
|
||||
def is_bottom_aligned(align: PageAlignment) -> bool:
|
||||
return align in (
|
||||
PageAlignment.BOTTOM_LEFT,
|
||||
PageAlignment.BOTTOM_CENTER,
|
||||
PageAlignment.BOTTOM_RIGHT,
|
||||
)
|
||||
@@ -0,0 +1,361 @@
|
||||
# Copyright (c) 2020-2023, Matthew Broadway
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Optional, Union
|
||||
import math
|
||||
import logging
|
||||
from os import PathLike
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from matplotlib.collections import LineCollection
|
||||
from matplotlib.image import AxesImage
|
||||
from matplotlib.lines import Line2D
|
||||
from matplotlib.patches import PathPatch
|
||||
from matplotlib.path import Path
|
||||
from matplotlib.transforms import Affine2D
|
||||
|
||||
from ezdxf.npshapes import to_matplotlib_path
|
||||
from ezdxf.addons.drawing.backend import Backend, BkPath2d, BkPoints2d, ImageData
|
||||
from ezdxf.addons.drawing.properties import BackendProperties, LayoutProperties
|
||||
from ezdxf.addons.drawing.type_hints import FilterFunc
|
||||
from ezdxf.addons.drawing.type_hints import Color
|
||||
from ezdxf.math import Vec2, Matrix44
|
||||
from ezdxf.layouts import Layout
|
||||
from .config import Configuration
|
||||
|
||||
logger = logging.getLogger("ezdxf")
|
||||
# matplotlib docs: https://matplotlib.org/index.html
|
||||
|
||||
# line style:
|
||||
# https://matplotlib.org/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D.set_linestyle
|
||||
# https://matplotlib.org/gallery/lines_bars_and_markers/linestyles.html
|
||||
|
||||
# line width:
|
||||
# https://matplotlib.org/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D.set_linewidth
|
||||
# points unit (pt), 1pt = 1/72 inch, 1pt = 0.3527mm
|
||||
POINTS = 1.0 / 0.3527 # mm -> points
|
||||
CURVE4x3 = (Path.CURVE4, Path.CURVE4, Path.CURVE4)
|
||||
SCATTER_POINT_SIZE = 0.1
|
||||
|
||||
|
||||
def setup_axes(ax: plt.Axes):
|
||||
# like set_axis_off, except that the face_color can still be set
|
||||
ax.xaxis.set_visible(False)
|
||||
ax.yaxis.set_visible(False)
|
||||
for s in ax.spines.values():
|
||||
s.set_visible(False)
|
||||
|
||||
ax.autoscale(False)
|
||||
ax.set_aspect("equal", "datalim")
|
||||
|
||||
|
||||
class MatplotlibBackend(Backend):
|
||||
"""Backend which uses the :mod:`Matplotlib` package for image export.
|
||||
|
||||
Args:
|
||||
ax: drawing canvas as :class:`matplotlib.pyplot.Axes` object
|
||||
adjust_figure: automatically adjust the size of the parent
|
||||
:class:`matplotlib.pyplot.Figure` to display all content
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ax: plt.Axes,
|
||||
*,
|
||||
adjust_figure: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
setup_axes(ax)
|
||||
self.ax = ax
|
||||
self._adjust_figure = adjust_figure
|
||||
self._current_z = 0
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
if config.min_lineweight is None:
|
||||
# If not set by user, use ~1 pixel
|
||||
figure = self.ax.get_figure()
|
||||
if figure:
|
||||
config = config.with_changes(min_lineweight=72.0 / figure.dpi)
|
||||
super().configure(config)
|
||||
# LinePolicy.ACCURATE is handled by the frontend since v0.18.1
|
||||
|
||||
def _get_z(self) -> int:
|
||||
z = self._current_z
|
||||
self._current_z += 1
|
||||
return z
|
||||
|
||||
def set_background(self, color: Color):
|
||||
self.ax.set_facecolor(color)
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties):
|
||||
"""Draw a real dimensionless point."""
|
||||
color = properties.color
|
||||
self.ax.scatter(
|
||||
[pos.x],
|
||||
[pos.y],
|
||||
s=SCATTER_POINT_SIZE,
|
||||
c=color,
|
||||
zorder=self._get_z(),
|
||||
)
|
||||
|
||||
def get_lineweight(self, properties: BackendProperties) -> float:
|
||||
"""Set lineweight_scaling=0 to use a constant minimal lineweight."""
|
||||
assert self.config.min_lineweight is not None
|
||||
return max(
|
||||
properties.lineweight * self.config.lineweight_scaling,
|
||||
self.config.min_lineweight,
|
||||
)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties):
|
||||
"""Draws a single solid line, line type rendering is done by the
|
||||
frontend since v0.18.1
|
||||
"""
|
||||
if start.isclose(end):
|
||||
# matplotlib draws nothing for a zero-length line:
|
||||
self.draw_point(start, properties)
|
||||
else:
|
||||
self.ax.add_line(
|
||||
Line2D(
|
||||
(start.x, end.x),
|
||||
(start.y, end.y),
|
||||
linewidth=self.get_lineweight(properties),
|
||||
color=properties.color,
|
||||
zorder=self._get_z(),
|
||||
)
|
||||
)
|
||||
|
||||
def draw_solid_lines(
|
||||
self,
|
||||
lines: Iterable[tuple[Vec2, Vec2]],
|
||||
properties: BackendProperties,
|
||||
):
|
||||
"""Fast method to draw a bunch of solid lines with the same properties."""
|
||||
color = properties.color
|
||||
lineweight = self.get_lineweight(properties)
|
||||
_lines = []
|
||||
point_x = []
|
||||
point_y = []
|
||||
z = self._get_z()
|
||||
for s, e in lines:
|
||||
if s.isclose(e):
|
||||
point_x.append(s.x)
|
||||
point_y.append(s.y)
|
||||
else:
|
||||
_lines.append(((s.x, s.y), (e.x, e.y)))
|
||||
|
||||
self.ax.scatter(point_x, point_y, s=SCATTER_POINT_SIZE, c=color, zorder=z)
|
||||
self.ax.add_collection(
|
||||
LineCollection(
|
||||
_lines,
|
||||
linewidths=lineweight,
|
||||
color=color,
|
||||
zorder=z,
|
||||
capstyle="butt",
|
||||
)
|
||||
)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: BackendProperties):
|
||||
"""Draw a solid line path, line type rendering is done by the
|
||||
frontend since v0.18.1
|
||||
"""
|
||||
|
||||
mpl_path = to_matplotlib_path([path])
|
||||
try:
|
||||
patch = PathPatch(
|
||||
mpl_path,
|
||||
linewidth=self.get_lineweight(properties),
|
||||
fill=False,
|
||||
color=properties.color,
|
||||
zorder=self._get_z(),
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.info(f"ignored matplotlib error: {str(e)}")
|
||||
else:
|
||||
self.ax.add_patch(patch)
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[BkPath2d], properties: BackendProperties
|
||||
):
|
||||
linewidth = 0
|
||||
|
||||
try:
|
||||
patch = PathPatch(
|
||||
to_matplotlib_path(paths, detect_holes=True),
|
||||
color=properties.color,
|
||||
linewidth=linewidth,
|
||||
fill=True,
|
||||
zorder=self._get_z(),
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.info(f"ignored matplotlib error in draw_filled_paths(): {str(e)}")
|
||||
else:
|
||||
self.ax.add_patch(patch)
|
||||
|
||||
def draw_filled_polygon(self, points: BkPoints2d, properties: BackendProperties):
|
||||
self.ax.fill(
|
||||
*zip(*((p.x, p.y) for p in points.vertices())),
|
||||
color=properties.color,
|
||||
linewidth=0,
|
||||
zorder=self._get_z(),
|
||||
)
|
||||
|
||||
def draw_image(
|
||||
self, image_data: ImageData, properties: BackendProperties
|
||||
) -> None:
|
||||
height, width, depth = image_data.image.shape
|
||||
assert depth == 4
|
||||
|
||||
# using AxesImage directly avoids an issue with ax.imshow where the data limits
|
||||
# are updated to include the un-transformed image because the transform is applied
|
||||
# afterward. We can use a slight hack which is that the outlines of images are drawn
|
||||
# as well as the image itself, so we don't have to adjust the data limits at all here
|
||||
# as the outline will take care of that
|
||||
handle = AxesImage(self.ax, interpolation="antialiased")
|
||||
handle.set_data(np.flip(image_data.image, axis=0))
|
||||
handle.set_zorder(self._get_z())
|
||||
|
||||
(
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
m44,
|
||||
) = image_data.transform
|
||||
matplotlib_transform = Affine2D(
|
||||
matrix=np.array(
|
||||
[
|
||||
[m11, m21, m41],
|
||||
[m12, m22, m42],
|
||||
[0, 0, 1],
|
||||
]
|
||||
)
|
||||
)
|
||||
handle.set_transform(matplotlib_transform + self.ax.transData)
|
||||
self.ax.add_image(handle)
|
||||
|
||||
def clear(self):
|
||||
self.ax.clear()
|
||||
|
||||
def finalize(self):
|
||||
super().finalize()
|
||||
self.ax.autoscale(True)
|
||||
if self._adjust_figure:
|
||||
minx, maxx = self.ax.get_xlim()
|
||||
miny, maxy = self.ax.get_ylim()
|
||||
data_width, data_height = maxx - minx, maxy - miny
|
||||
if not math.isclose(data_width, 0):
|
||||
width, height = plt.figaspect(data_height / data_width)
|
||||
self.ax.get_figure().set_size_inches(width, height, forward=True)
|
||||
|
||||
|
||||
def _get_aspect_ratio(ax: plt.Axes) -> float:
|
||||
minx, maxx = ax.get_xlim()
|
||||
miny, maxy = ax.get_ylim()
|
||||
data_width, data_height = maxx - minx, maxy - miny
|
||||
if abs(data_height) > 1e-9:
|
||||
return data_width / data_height
|
||||
return 1.0
|
||||
|
||||
|
||||
def _get_width_height(ratio: float, width: float, height: float) -> tuple[float, float]:
|
||||
if width == 0.0 and height == 0.0:
|
||||
raise ValueError("invalid (width, height) values")
|
||||
if width == 0.0:
|
||||
width = height * ratio
|
||||
elif height == 0.0:
|
||||
height = width / ratio
|
||||
return width, height
|
||||
|
||||
|
||||
def qsave(
|
||||
layout: Layout,
|
||||
filename: Union[str, PathLike],
|
||||
*,
|
||||
bg: Optional[Color] = None,
|
||||
fg: Optional[Color] = None,
|
||||
dpi: int = 300,
|
||||
backend: str = "agg",
|
||||
config: Optional[Configuration] = None,
|
||||
filter_func: Optional[FilterFunc] = None,
|
||||
size_inches: Optional[tuple[float, float]] = None,
|
||||
) -> None:
|
||||
"""Quick and simplified render export by matplotlib.
|
||||
|
||||
Args:
|
||||
layout: modelspace or paperspace layout to export
|
||||
filename: export filename, file extension determines the format e.g.
|
||||
"image.png" to save in PNG format.
|
||||
bg: override default background color in hex format #RRGGBB or #RRGGBBAA,
|
||||
e.g. use bg="#FFFFFF00" to get a transparent background and a black
|
||||
foreground color (ACI=7), because a white background #FFFFFF gets a
|
||||
black foreground color or vice versa bg="#00000000" for a transparent
|
||||
(black) background and a white foreground color.
|
||||
fg: override default foreground color in hex format #RRGGBB or #RRGGBBAA,
|
||||
requires also `bg` argument. There is no explicit foreground color
|
||||
in DXF defined (also not a background color), but the ACI color 7
|
||||
has already a variable color value, black on a light background and
|
||||
white on a dark background, this argument overrides this (ACI=7)
|
||||
default color value.
|
||||
dpi: image resolution (dots per inches).
|
||||
size_inches: paper size in inch as `(width, height)` tuple, which also
|
||||
defines the size in pixels = (`width` * `dpi`) x (`height` * `dpi`).
|
||||
If `width` or `height` is 0.0 the value is calculated by the aspect
|
||||
ratio of the drawing.
|
||||
backend: the matplotlib rendering backend to use (agg, cairo, svg etc)
|
||||
(see documentation for `matplotlib.use() <https://matplotlib.org/3.1.1/api/matplotlib_configuration_api.html?highlight=matplotlib%20use#matplotlib.use>`_
|
||||
for a complete list of backends)
|
||||
config: drawing parameters
|
||||
filter_func: filter function which takes a DXFGraphic object as input
|
||||
and returns ``True`` if the entity should be drawn or ``False`` if
|
||||
the entity should be ignored
|
||||
|
||||
"""
|
||||
from .properties import RenderContext
|
||||
from .frontend import Frontend
|
||||
import matplotlib
|
||||
|
||||
# Set the backend to prevent warnings about GUIs being opened from a thread
|
||||
# other than the main thread.
|
||||
old_backend = matplotlib.get_backend()
|
||||
matplotlib.use(backend)
|
||||
if config is None:
|
||||
config = Configuration()
|
||||
|
||||
try:
|
||||
fig: plt.Figure = plt.figure(dpi=dpi)
|
||||
ax: plt.Axes = fig.add_axes((0, 0, 1, 1))
|
||||
ctx = RenderContext(layout.doc)
|
||||
layout_properties = LayoutProperties.from_layout(layout)
|
||||
if bg is not None:
|
||||
layout_properties.set_colors(bg, fg)
|
||||
out = MatplotlibBackend(ax)
|
||||
Frontend(ctx, out, config).draw_layout(
|
||||
layout,
|
||||
finalize=True,
|
||||
filter_func=filter_func,
|
||||
layout_properties=layout_properties,
|
||||
)
|
||||
# transparent=True sets the axes color to fully transparent
|
||||
# facecolor sets the figure color
|
||||
# (semi-)transparent axes colors do not produce transparent outputs
|
||||
# but (semi-)transparent figure colors do.
|
||||
if size_inches is not None:
|
||||
ratio = _get_aspect_ratio(ax)
|
||||
w, h = _get_width_height(ratio, size_inches[0], size_inches[1])
|
||||
fig.set_size_inches(w, h, True)
|
||||
fig.savefig(filename, dpi=dpi, facecolor=ax.get_facecolor(), transparent=True)
|
||||
plt.close(fig)
|
||||
finally:
|
||||
matplotlib.use(old_backend)
|
||||
@@ -0,0 +1,310 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Optional
|
||||
from typing_extensions import Protocol
|
||||
import copy
|
||||
import math
|
||||
|
||||
from ezdxf import colors
|
||||
from ezdxf.entities import MText
|
||||
from ezdxf.lldxf import const
|
||||
from ezdxf.math import Matrix44, Vec3, AnyVec
|
||||
from ezdxf.render.abstract_mtext_renderer import AbstractMTextRenderer
|
||||
from ezdxf.fonts import fonts
|
||||
from ezdxf.tools import text_layout as tl
|
||||
from ezdxf.tools.text import MTextContext
|
||||
from .properties import Properties, RenderContext, rgb_to_hex
|
||||
from .type_hints import Color
|
||||
|
||||
__all__ = ["complex_mtext_renderer"]
|
||||
|
||||
|
||||
def corner_vertices(
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Matrix44 = None,
|
||||
) -> Iterable[Vec3]:
|
||||
corners = [ # closed polygon: fist vertex == last vertex
|
||||
(left, top),
|
||||
(right, top),
|
||||
(right, bottom),
|
||||
(left, bottom),
|
||||
(left, top),
|
||||
]
|
||||
if m is None:
|
||||
return Vec3.generate(corners)
|
||||
else:
|
||||
return m.transform_vertices(corners)
|
||||
|
||||
|
||||
class DrawInterface(Protocol):
|
||||
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties) -> None:
|
||||
...
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: Iterable[AnyVec], properties: Properties
|
||||
) -> None:
|
||||
...
|
||||
|
||||
def draw_text(
|
||||
self,
|
||||
text: str,
|
||||
transform: Matrix44,
|
||||
properties: Properties,
|
||||
cap_height: float,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
|
||||
class FrameRenderer(tl.ContentRenderer):
|
||||
def __init__(self, properties: Properties, backend: DrawInterface):
|
||||
self.properties = properties
|
||||
self.backend = backend
|
||||
|
||||
def render(
|
||||
self,
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Matrix44 = None,
|
||||
) -> None:
|
||||
self._render_outline(list(corner_vertices(left, bottom, right, top, m)))
|
||||
|
||||
def _render_outline(self, vertices: list[Vec3]) -> None:
|
||||
backend = self.backend
|
||||
properties = self.properties
|
||||
prev = vertices.pop(0)
|
||||
for vertex in vertices:
|
||||
backend.draw_line(prev, vertex, properties)
|
||||
prev = vertex
|
||||
|
||||
def line(
|
||||
self, x1: float, y1: float, x2: float, y2: float, m: Matrix44 = None
|
||||
) -> None:
|
||||
points = [(x1, y1), (x2, y2)]
|
||||
if m is not None:
|
||||
p1, p2 = m.transform_vertices(points)
|
||||
else:
|
||||
p1, p2 = Vec3.generate(points)
|
||||
self.backend.draw_line(p1, p2, self.properties)
|
||||
|
||||
|
||||
class ColumnBackgroundRenderer(FrameRenderer):
|
||||
def __init__(
|
||||
self,
|
||||
properties: Properties,
|
||||
backend: DrawInterface,
|
||||
bg_properties: Optional[Properties] = None,
|
||||
offset: float = 0,
|
||||
text_frame: bool = False,
|
||||
):
|
||||
super().__init__(properties, backend)
|
||||
self.bg_properties = bg_properties
|
||||
self.offset = offset # background border offset
|
||||
self.has_text_frame = text_frame
|
||||
|
||||
def render(
|
||||
self,
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Matrix44 = None,
|
||||
) -> None:
|
||||
# Important: this is not a clipping box, it is possible to
|
||||
# render anything outside of the given borders!
|
||||
offset = self.offset
|
||||
vertices = list(
|
||||
corner_vertices(
|
||||
left - offset, bottom - offset, right + offset, top + offset, m
|
||||
)
|
||||
)
|
||||
if self.bg_properties is not None:
|
||||
self.backend.draw_filled_polygon(vertices, self.bg_properties)
|
||||
if self.has_text_frame:
|
||||
self._render_outline(vertices)
|
||||
|
||||
|
||||
class TextRenderer(FrameRenderer):
|
||||
"""Text content renderer."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
cap_height: float,
|
||||
width_factor: float,
|
||||
oblique: float, # angle in degrees
|
||||
properties: Properties,
|
||||
backend: DrawInterface,
|
||||
):
|
||||
super().__init__(properties, backend)
|
||||
self.text = text
|
||||
self.cap_height = cap_height
|
||||
self.width_factor = width_factor
|
||||
self.oblique = oblique # angle in degrees
|
||||
|
||||
def render(
|
||||
self,
|
||||
left: float,
|
||||
bottom: float,
|
||||
right: float,
|
||||
top: float,
|
||||
m: Matrix44 = None,
|
||||
):
|
||||
"""Create/render the text content"""
|
||||
sx = 1.0
|
||||
tx = 0.0
|
||||
if not math.isclose(self.width_factor, 1.0, rel_tol=1e-6):
|
||||
sx = self.width_factor
|
||||
if abs(self.oblique) > 1e-3: # degrees
|
||||
tx = math.tan(math.radians(self.oblique))
|
||||
# fmt: off
|
||||
t = Matrix44((
|
||||
sx, 0.0, 0.0, 0.0,
|
||||
tx, 1.0, 0.0, 0.0,
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
left, bottom, 0.0, 1.0
|
||||
))
|
||||
# fmt: on
|
||||
if m is not None:
|
||||
t *= m
|
||||
self.backend.draw_text(self.text, t, self.properties, self.cap_height)
|
||||
|
||||
|
||||
def complex_mtext_renderer(
|
||||
ctx: RenderContext,
|
||||
backend: DrawInterface,
|
||||
mtext: MText,
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
cmr = ComplexMTextRenderer(ctx, backend, properties)
|
||||
align = tl.LayoutAlignment(mtext.dxf.attachment_point)
|
||||
layout_engine = cmr.layout_engine(mtext)
|
||||
layout_engine.place(align=align)
|
||||
layout_engine.render(mtext.ucs().matrix)
|
||||
|
||||
|
||||
class ComplexMTextRenderer(AbstractMTextRenderer):
|
||||
def __init__(
|
||||
self,
|
||||
ctx: RenderContext,
|
||||
backend: DrawInterface,
|
||||
properties: Properties,
|
||||
):
|
||||
super().__init__()
|
||||
self._render_ctx = ctx
|
||||
self._backend = backend
|
||||
self._properties = properties
|
||||
|
||||
# Implementation of required AbstractMTextRenderer methods:
|
||||
|
||||
def word(self, text: str, ctx: MTextContext) -> tl.ContentCell:
|
||||
return tl.Text(
|
||||
width=self.get_font(ctx).text_width(text),
|
||||
height=ctx.cap_height,
|
||||
valign=tl.CellAlignment(ctx.align),
|
||||
stroke=self.get_stroke(ctx),
|
||||
renderer=TextRenderer(
|
||||
text,
|
||||
ctx.cap_height,
|
||||
ctx.width_factor,
|
||||
ctx.oblique,
|
||||
self.new_text_properties(self._properties, ctx),
|
||||
self._backend,
|
||||
),
|
||||
)
|
||||
|
||||
def fraction(self, data: tuple[str, str, str], ctx: MTextContext) -> tl.ContentCell:
|
||||
upr, lwr, type_ = data
|
||||
if type_:
|
||||
return tl.Fraction(
|
||||
top=self.word(upr, ctx),
|
||||
bottom=self.word(lwr, ctx),
|
||||
stacking=self.get_stacking(type_),
|
||||
# renders just the divider line:
|
||||
renderer=FrameRenderer(self._properties, self._backend),
|
||||
)
|
||||
else:
|
||||
return self.word(upr, ctx)
|
||||
|
||||
def get_font_face(self, mtext: MText) -> fonts.FontFace:
|
||||
return self._properties.font # type: ignore
|
||||
|
||||
def make_bg_renderer(self, mtext: MText) -> tl.ContentRenderer:
|
||||
dxf = mtext.dxf
|
||||
bg_fill = dxf.get("bg_fill", 0)
|
||||
|
||||
bg_aci = None
|
||||
bg_true_color = None
|
||||
bg_properties: Optional[Properties] = None
|
||||
has_text_frame = False
|
||||
offset = 0
|
||||
if bg_fill:
|
||||
# The fill scale is a multiple of the initial char height and
|
||||
# a scale of 1, fits exact the outer border
|
||||
# of the column -> offset = 0
|
||||
offset = dxf.char_height * (dxf.get("box_fill_scale", 1.5) - 1)
|
||||
if bg_fill & const.MTEXT_BG_COLOR:
|
||||
if dxf.hasattr("bg_fill_color"):
|
||||
bg_aci = dxf.bg_fill_color
|
||||
|
||||
if dxf.hasattr("bg_fill_true_color"):
|
||||
bg_aci = None
|
||||
bg_true_color = dxf.bg_fill_true_color
|
||||
|
||||
if (bg_fill & 3) == 3: # canvas color = bit 0 and 1 set
|
||||
# can not detect canvas color from DXF document!
|
||||
# do not draw any background:
|
||||
bg_aci = None
|
||||
bg_true_color = None
|
||||
|
||||
if bg_fill & const.MTEXT_TEXT_FRAME:
|
||||
has_text_frame = True
|
||||
bg_properties = self.new_bg_properties(bg_aci, bg_true_color)
|
||||
|
||||
return ColumnBackgroundRenderer(
|
||||
self._properties,
|
||||
self._backend,
|
||||
bg_properties,
|
||||
offset=offset,
|
||||
text_frame=has_text_frame,
|
||||
)
|
||||
|
||||
# Implementation details of ComplexMTextRenderer:
|
||||
|
||||
@property
|
||||
def backend(self) -> DrawInterface:
|
||||
return self._backend
|
||||
|
||||
def resolve_aci_color(self, aci: int) -> Color:
|
||||
return self._render_ctx.resolve_aci_color(aci, self._properties.layer)
|
||||
|
||||
def new_text_properties(
|
||||
self, properties: Properties, ctx: MTextContext
|
||||
) -> Properties:
|
||||
new_properties = copy.copy(properties)
|
||||
if ctx.rgb is None:
|
||||
new_properties.color = self.resolve_aci_color(ctx.aci)
|
||||
else:
|
||||
new_properties.color = rgb_to_hex(ctx.rgb)
|
||||
new_properties.font = ctx.font_face
|
||||
return new_properties
|
||||
|
||||
def new_bg_properties(
|
||||
self, aci: Optional[int], true_color: Optional[int]
|
||||
) -> Properties:
|
||||
new_properties = copy.copy(self._properties)
|
||||
new_properties.color = ( # canvas background color
|
||||
self._render_ctx.current_layout_properties.background_color
|
||||
)
|
||||
if true_color is None:
|
||||
if aci is not None:
|
||||
new_properties.color = self.resolve_aci_color(aci)
|
||||
# else canvas background color
|
||||
else:
|
||||
new_properties.color = rgb_to_hex(colors.int2rgb(true_color))
|
||||
return new_properties
|
||||
@@ -0,0 +1,886 @@
|
||||
# Copyright (c) 2023-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Sequence,
|
||||
Optional,
|
||||
Iterable,
|
||||
Tuple,
|
||||
Iterator,
|
||||
Callable,
|
||||
)
|
||||
from typing_extensions import TypeAlias
|
||||
import abc
|
||||
|
||||
import numpy as np
|
||||
import PIL.Image
|
||||
import PIL.ImageDraw
|
||||
import PIL.ImageOps
|
||||
|
||||
|
||||
from ezdxf.colors import RGB
|
||||
import ezdxf.bbox
|
||||
|
||||
from ezdxf.fonts import fonts
|
||||
from ezdxf.math import Vec2, Matrix44, BoundingBox2d, AnyVec
|
||||
from ezdxf.path import make_path, Path
|
||||
from ezdxf.render import linetypes
|
||||
from ezdxf.entities import DXFGraphic, Viewport
|
||||
from ezdxf.tools.text import replace_non_printable_characters
|
||||
from ezdxf.tools.clipping_portal import (
|
||||
ClippingPortal,
|
||||
ClippingShape,
|
||||
find_best_clipping_shape,
|
||||
)
|
||||
from ezdxf.layouts import Layout
|
||||
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
|
||||
from .config import LinePolicy, TextPolicy, ColorPolicy, Configuration
|
||||
from .properties import BackendProperties, Filling
|
||||
from .properties import Properties, RenderContext
|
||||
from .type_hints import Color
|
||||
from .unified_text_renderer import UnifiedTextRenderer
|
||||
|
||||
PatternKey: TypeAlias = Tuple[str, float]
|
||||
DrawEntitiesCallback: TypeAlias = Callable[[RenderContext, Iterable[DXFGraphic]], None]
|
||||
|
||||
__all__ = ["AbstractPipeline", "RenderPipeline2d"]
|
||||
|
||||
|
||||
class AbstractPipeline(abc.ABC):
|
||||
"""This drawing pipeline separates the frontend from the backend and implements
|
||||
these features:
|
||||
|
||||
- automatically linetype rendering
|
||||
- font rendering
|
||||
- VIEWPORT rendering
|
||||
- foreground color mapping according Frontend.config.color_policy
|
||||
|
||||
The pipeline is organized as concatenated render stages.
|
||||
|
||||
"""
|
||||
|
||||
text_engine = UnifiedTextRenderer()
|
||||
default_font_face = fonts.FontFace()
|
||||
draw_entities: DrawEntitiesCallback
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_draw_entities_callback(self, callback: DrawEntitiesCallback) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_config(self, config: Configuration) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_current_entity_handle(self, handle: str) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def push_clipping_shape(
|
||||
self, shape: ClippingShape, transform: Matrix44 | None
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def pop_clipping_shape(self) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_viewport(
|
||||
self,
|
||||
vp: Viewport,
|
||||
layout_ctx: RenderContext,
|
||||
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
|
||||
) -> None:
|
||||
"""Draw the content of the given viewport current viewport."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_point(self, pos: AnyVec, properties: Properties) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties): ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[AnyVec, AnyVec]], properties: Properties
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_path(self, path: Path, properties: Properties): ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: Iterable[Path],
|
||||
properties: Properties,
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_filled_polygon(
|
||||
self, points: Iterable[AnyVec], properties: Properties
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_text(
|
||||
self,
|
||||
text: str,
|
||||
transform: Matrix44,
|
||||
properties: Properties,
|
||||
cap_height: float,
|
||||
dxftype: str = "TEXT",
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def finalize(self) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_background(self, color: Color) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
|
||||
# gets the full DXF properties information
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def exit_entity(self, entity: DXFGraphic) -> None: ...
|
||||
|
||||
|
||||
class RenderStage2d(abc.ABC):
|
||||
next_stage: RenderStage2d
|
||||
|
||||
def set_config(self, config: Configuration) -> None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_point(self, pos: Vec2, properties: Properties) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: Properties): ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_solid_lines(
|
||||
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_path(self, path: BkPath2d, properties: Properties): ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: list[BkPath2d],
|
||||
properties: Properties,
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_filled_polygon(
|
||||
self, points: BkPoints2d, properties: Properties
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None: ...
|
||||
|
||||
|
||||
class RenderPipeline2d(AbstractPipeline):
|
||||
"""Render pipeline for 2D backends."""
|
||||
|
||||
def __init__(self, backend: BackendInterface):
|
||||
self.backend = backend
|
||||
self.config = Configuration()
|
||||
try: # request default font face
|
||||
self.default_font_face = fonts.font_manager.get_font_face("")
|
||||
except fonts.FontNotFoundError: # no default font found
|
||||
# last resort MonospaceFont which renders only "tofu"
|
||||
pass
|
||||
self.clipping_portal = ClippingPortal()
|
||||
self.current_vp_scale = 1.0
|
||||
self._current_entity_handle: str = ""
|
||||
self._color_mapping: dict[str, str] = dict()
|
||||
self._pipeline = self.build_render_pipeline()
|
||||
|
||||
def build_render_pipeline(self) -> RenderStage2d:
|
||||
backend_stage = BackendStage2d(
|
||||
self.backend, converter=self.get_backend_properties
|
||||
)
|
||||
linetype_stage = LinetypeStage2d(
|
||||
self.config,
|
||||
get_ltype_scale=self.get_vp_ltype_scale,
|
||||
next_stage=backend_stage,
|
||||
)
|
||||
clipping_stage = ClippingStage2d(
|
||||
self.config, self.clipping_portal, next_stage=linetype_stage
|
||||
)
|
||||
return clipping_stage
|
||||
|
||||
def get_vp_ltype_scale(self) -> float:
|
||||
"""The linetype pattern should look the same in all viewports
|
||||
regardless of the viewport scale.
|
||||
"""
|
||||
return 1.0 / max(self.current_vp_scale, 0.0001) # max out at 1:10000
|
||||
|
||||
def get_backend_properties(self, properties: Properties) -> BackendProperties:
|
||||
try:
|
||||
color = self._color_mapping[properties.color]
|
||||
except KeyError:
|
||||
color = apply_color_policy(
|
||||
properties.color, self.config.color_policy, self.config.custom_fg_color
|
||||
)
|
||||
self._color_mapping[properties.color] = color
|
||||
return BackendProperties(
|
||||
color,
|
||||
properties.lineweight,
|
||||
properties.layer,
|
||||
properties.pen,
|
||||
self._current_entity_handle,
|
||||
)
|
||||
|
||||
def set_draw_entities_callback(self, callback: DrawEntitiesCallback) -> None:
|
||||
self.draw_entities = callback
|
||||
|
||||
def set_config(self, config: Configuration) -> None:
|
||||
self.backend.configure(config)
|
||||
self.config = config
|
||||
stage = self._pipeline
|
||||
while True:
|
||||
stage.set_config(config)
|
||||
if not hasattr(stage, "next_stage"): # BackendStage2d
|
||||
return
|
||||
stage = stage.next_stage
|
||||
|
||||
def set_current_entity_handle(self, handle: str) -> None:
|
||||
assert handle is not None
|
||||
self._current_entity_handle = handle
|
||||
|
||||
def push_clipping_shape(
|
||||
self, shape: ClippingShape, transform: Matrix44 | None
|
||||
) -> None:
|
||||
self.clipping_portal.push(shape, transform)
|
||||
|
||||
def pop_clipping_shape(self) -> None:
|
||||
self.clipping_portal.pop()
|
||||
|
||||
def draw_viewport(
|
||||
self,
|
||||
vp: Viewport,
|
||||
layout_ctx: RenderContext,
|
||||
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
|
||||
) -> None:
|
||||
"""Draw the content of the given viewport current viewport."""
|
||||
if vp.doc is None:
|
||||
return
|
||||
try:
|
||||
msp_limits = vp.get_modelspace_limits()
|
||||
except ValueError: # modelspace limits not detectable
|
||||
return
|
||||
if self.enter_viewport(vp):
|
||||
self.draw_entities(
|
||||
layout_ctx.from_viewport(vp),
|
||||
filter_vp_entities(vp.doc.modelspace(), msp_limits, bbox_cache),
|
||||
)
|
||||
self.exit_viewport()
|
||||
|
||||
def enter_viewport(self, vp: Viewport) -> bool:
|
||||
"""Set current viewport, returns ``True`` for valid viewports."""
|
||||
self.current_vp_scale = vp.get_scale()
|
||||
m = vp.get_transformation_matrix()
|
||||
clipping_path = make_path(vp)
|
||||
if len(clipping_path):
|
||||
vertices = clipping_path.control_vertices()
|
||||
if clipping_path.has_curves:
|
||||
layout = vp.get_layout()
|
||||
if isinstance(layout, Layout):
|
||||
# plot paper units:
|
||||
# 0: inches, max sagitta = 1/254 = 0.1 mm
|
||||
# 1: millimeters, max sagitta = 0.1 mm
|
||||
# 2: pixels, max sagitta = 0.1 pixel
|
||||
units = layout.dxf.get("plot_paper_units", 1)
|
||||
max_sagitta = 1.0 / 254.0 if units == 0 else 0.1
|
||||
vertices = list(clipping_path.flattening(max_sagitta))
|
||||
clipping_shape = find_best_clipping_shape(vertices)
|
||||
self.clipping_portal.push(clipping_shape, m)
|
||||
return True
|
||||
return False
|
||||
|
||||
def exit_viewport(self):
|
||||
self.clipping_portal.pop()
|
||||
# Reset viewport scaling: viewports cannot be nested!
|
||||
self.current_vp_scale = 1.0
|
||||
|
||||
def draw_text(
|
||||
self,
|
||||
text: str,
|
||||
transform: Matrix44,
|
||||
properties: Properties,
|
||||
cap_height: float,
|
||||
dxftype: str = "TEXT",
|
||||
) -> None:
|
||||
"""Render text as filled paths."""
|
||||
text_policy = self.config.text_policy
|
||||
pipeline = self._pipeline
|
||||
|
||||
if not text.strip() or text_policy == TextPolicy.IGNORE:
|
||||
return # no point rendering empty strings
|
||||
text = prepare_string_for_rendering(text, dxftype)
|
||||
font_face = properties.font
|
||||
if font_face is None:
|
||||
font_face = self.default_font_face
|
||||
|
||||
try:
|
||||
glyph_paths = self.text_engine.get_text_glyph_paths(
|
||||
text, font_face, cap_height
|
||||
)
|
||||
except (RuntimeError, ValueError):
|
||||
return
|
||||
for p in glyph_paths:
|
||||
p.transform_inplace(transform)
|
||||
transformed_paths: list[BkPath2d] = glyph_paths
|
||||
|
||||
points: list[Vec2]
|
||||
if text_policy == TextPolicy.REPLACE_RECT:
|
||||
points = []
|
||||
for p in transformed_paths:
|
||||
points.extend(p.extents())
|
||||
if len(points) < 2:
|
||||
return
|
||||
rect = BkPath2d.from_vertices(BoundingBox2d(points).rect_vertices())
|
||||
pipeline.draw_path(rect, properties)
|
||||
return
|
||||
if text_policy == TextPolicy.REPLACE_FILL:
|
||||
points = []
|
||||
for p in transformed_paths:
|
||||
points.extend(p.extents())
|
||||
if len(points) < 2:
|
||||
return
|
||||
polygon = BkPoints2d(BoundingBox2d(points).rect_vertices())
|
||||
if properties.filling is None:
|
||||
properties.filling = Filling()
|
||||
pipeline.draw_filled_polygon(polygon, properties)
|
||||
return
|
||||
|
||||
if (
|
||||
self.text_engine.is_stroke_font(font_face)
|
||||
or text_policy == TextPolicy.OUTLINE
|
||||
):
|
||||
for text_path in transformed_paths:
|
||||
pipeline.draw_path(text_path, properties)
|
||||
return
|
||||
|
||||
if properties.filling is None:
|
||||
properties.filling = Filling()
|
||||
pipeline.draw_filled_paths(transformed_paths, properties)
|
||||
|
||||
def finalize(self) -> None:
|
||||
self.backend.finalize()
|
||||
|
||||
def set_background(self, color: Color) -> None:
|
||||
self.backend.set_background(color)
|
||||
|
||||
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
|
||||
self.backend.enter_entity(entity, properties)
|
||||
|
||||
def exit_entity(self, entity: DXFGraphic) -> None:
|
||||
self.backend.exit_entity(entity)
|
||||
|
||||
# Enter render pipeline:
|
||||
def draw_point(self, pos: AnyVec, properties: Properties) -> None:
|
||||
self._pipeline.draw_point(Vec2(pos), properties)
|
||||
|
||||
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties):
|
||||
self._pipeline.draw_line(Vec2(start), Vec2(end), properties)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[AnyVec, AnyVec]], properties: Properties
|
||||
) -> None:
|
||||
self._pipeline.draw_solid_lines(
|
||||
[(Vec2(s), Vec2(e)) for s, e in lines], properties
|
||||
)
|
||||
|
||||
def draw_path(self, path: Path, properties: Properties):
|
||||
self._pipeline.draw_path(BkPath2d(path), properties)
|
||||
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: Iterable[Path],
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
self._pipeline.draw_filled_paths(list(map(BkPath2d, paths)), properties)
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: Iterable[AnyVec], properties: Properties
|
||||
) -> None:
|
||||
self._pipeline.draw_filled_polygon(BkPoints2d(points), properties)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
|
||||
self._pipeline.draw_image(image_data, properties)
|
||||
|
||||
|
||||
class ClippingStage2d(RenderStage2d):
|
||||
def __init__(
|
||||
self,
|
||||
config: Configuration,
|
||||
clipping_portal: ClippingPortal,
|
||||
next_stage: RenderStage2d,
|
||||
):
|
||||
self.clipping_portal = clipping_portal
|
||||
self.config = config
|
||||
self.next_stage = next_stage
|
||||
|
||||
def set_config(self, config: Configuration) -> None:
|
||||
self.config = config
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: Properties) -> None:
|
||||
if self.clipping_portal.is_active:
|
||||
pos = self.clipping_portal.clip_point(pos)
|
||||
if pos is None:
|
||||
return
|
||||
self.next_stage.draw_point(pos, properties)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
|
||||
next_stage = self.next_stage
|
||||
clipping_portal = self.clipping_portal
|
||||
|
||||
if clipping_portal.is_active:
|
||||
for segment in clipping_portal.clip_line(start, end):
|
||||
next_stage.draw_line(segment[0], segment[1], properties)
|
||||
return
|
||||
next_stage.draw_line(start, end, properties)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
|
||||
) -> None:
|
||||
clipping_portal = self.clipping_portal
|
||||
|
||||
if clipping_portal.is_active:
|
||||
cropped_lines: list[tuple[Vec2, Vec2]] = []
|
||||
for start, end in lines:
|
||||
cropped_lines.extend(clipping_portal.clip_line(start, end))
|
||||
lines = cropped_lines
|
||||
self.next_stage.draw_solid_lines(lines, properties)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: Properties):
|
||||
clipping_portal = self.clipping_portal
|
||||
next_stage = self.next_stage
|
||||
max_sagitta = self.config.max_flattening_distance
|
||||
|
||||
if clipping_portal.is_active:
|
||||
for clipped_path in clipping_portal.clip_paths([path], max_sagitta):
|
||||
next_stage.draw_path(clipped_path, properties)
|
||||
return
|
||||
next_stage.draw_path(path, properties)
|
||||
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: list[BkPath2d],
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
clipping_portal = self.clipping_portal
|
||||
max_sagitta = self.config.max_flattening_distance
|
||||
|
||||
if clipping_portal.is_active:
|
||||
paths = clipping_portal.clip_filled_paths(paths, max_sagitta)
|
||||
if len(paths) == 0:
|
||||
return
|
||||
self.next_stage.draw_filled_paths(paths, properties)
|
||||
|
||||
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
|
||||
clipping_portal = self.clipping_portal
|
||||
next_stage = self.next_stage
|
||||
|
||||
if clipping_portal.is_active:
|
||||
for points in clipping_portal.clip_polygon(points):
|
||||
if len(points) > 0:
|
||||
next_stage.draw_filled_polygon(points, properties)
|
||||
return
|
||||
|
||||
if len(points) > 0:
|
||||
next_stage.draw_filled_polygon(points, properties)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
|
||||
# the outer bounds contain the visible parts of the image for the
|
||||
# clip mode "remove inside"
|
||||
outer_bounds: list[BkPoints2d] = []
|
||||
clipping_portal = self.clipping_portal
|
||||
|
||||
if not clipping_portal.is_active:
|
||||
self._draw_image(image_data, outer_bounds, properties)
|
||||
return
|
||||
|
||||
# the pixel boundary path can be split into multiple paths
|
||||
transform = image_data.flip_matrix() * image_data.transform
|
||||
pixel_boundary_path = image_data.pixel_boundary_path
|
||||
clipping_paths = _clip_image_polygon(
|
||||
clipping_portal, pixel_boundary_path, transform
|
||||
)
|
||||
if not image_data.remove_outside:
|
||||
# remove inside:
|
||||
# detect the visible parts of the image which are not removed by
|
||||
# clipping through viewports or block references
|
||||
width, height = image_data.image_size()
|
||||
outer_boundary = BkPoints2d(
|
||||
Vec2.generate([(0, 0), (width, 0), (width, height), (0, height)])
|
||||
)
|
||||
outer_bounds = _clip_image_polygon(
|
||||
clipping_portal, outer_boundary, transform
|
||||
)
|
||||
image_data.transform = clipping_portal.transform_matrix(image_data.transform)
|
||||
if len(clipping_paths) == 1:
|
||||
new_clipping_path = clipping_paths[0]
|
||||
if new_clipping_path is not image_data.pixel_boundary_path:
|
||||
image_data.pixel_boundary_path = new_clipping_path
|
||||
# forced clipping triggered by viewport- or block reference clipping:
|
||||
image_data.use_clipping_boundary = True
|
||||
self._draw_image(image_data, outer_bounds, properties)
|
||||
else:
|
||||
for clipping_path in clipping_paths:
|
||||
# when clipping path is split into multiple parts:
|
||||
# copy image for each part, not efficient but works
|
||||
# this should be a rare usecase so optimization is not required
|
||||
self._draw_image(
|
||||
ImageData(
|
||||
image=image_data.image.copy(),
|
||||
transform=image_data.transform,
|
||||
pixel_boundary_path=clipping_path,
|
||||
use_clipping_boundary=True,
|
||||
),
|
||||
outer_bounds,
|
||||
properties,
|
||||
)
|
||||
|
||||
def _draw_image(
|
||||
self,
|
||||
image_data: ImageData,
|
||||
outer_bounds: list[BkPoints2d],
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
if image_data.use_clipping_boundary:
|
||||
_mask_image(image_data, outer_bounds)
|
||||
self.next_stage.draw_image(image_data, properties)
|
||||
|
||||
|
||||
class LinetypeStage2d(RenderStage2d):
|
||||
def __init__(
|
||||
self,
|
||||
config: Configuration,
|
||||
get_ltype_scale: Callable[[], float],
|
||||
next_stage: RenderStage2d,
|
||||
):
|
||||
self.config = config
|
||||
self.solid_lines_only = False
|
||||
self.next_stage = next_stage
|
||||
self.get_ltype_scale = get_ltype_scale
|
||||
self.pattern_cache: dict[PatternKey, Sequence[float]] = dict()
|
||||
self.set_config(config)
|
||||
|
||||
def set_config(self, config: Configuration) -> None:
|
||||
self.config = config
|
||||
self.solid_lines_only = config.line_policy == LinePolicy.SOLID
|
||||
|
||||
def pattern(self, properties: Properties) -> Sequence[float]:
|
||||
"""Returns simplified linetype tuple: on-off sequence"""
|
||||
if self.solid_lines_only:
|
||||
scale = 0.0
|
||||
else:
|
||||
scale = properties.linetype_scale * self.get_ltype_scale()
|
||||
|
||||
key: PatternKey = (properties.linetype_name, scale)
|
||||
pattern_ = self.pattern_cache.get(key)
|
||||
if pattern_ is None:
|
||||
pattern_ = self._create_pattern(properties, scale)
|
||||
self.pattern_cache[key] = pattern_
|
||||
return pattern_
|
||||
|
||||
def _create_pattern(self, properties: Properties, scale: float) -> Sequence[float]:
|
||||
if len(properties.linetype_pattern) < 2:
|
||||
# Do not return None -> None indicates: "not cached"
|
||||
return tuple()
|
||||
|
||||
min_dash_length = self.config.min_dash_length * self.get_ltype_scale()
|
||||
pattern = [max(e * scale, min_dash_length) for e in properties.linetype_pattern]
|
||||
if len(pattern) % 2:
|
||||
pattern.pop()
|
||||
return pattern
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: Properties) -> None:
|
||||
self.next_stage.draw_point(pos, properties)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
|
||||
s = Vec2(start)
|
||||
e = Vec2(end)
|
||||
next_stage = self.next_stage
|
||||
|
||||
if self.solid_lines_only or len(properties.linetype_pattern) < 2: # CONTINUOUS
|
||||
next_stage.draw_line(s, e, properties)
|
||||
return
|
||||
|
||||
renderer = linetypes.LineTypeRenderer(self.pattern(properties))
|
||||
next_stage.draw_solid_lines(
|
||||
[(s, e) for s, e in renderer.line_segment(s, e)],
|
||||
properties,
|
||||
)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
|
||||
) -> None:
|
||||
self.next_stage.draw_solid_lines(lines, properties)
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: Properties):
|
||||
next_stage = self.next_stage
|
||||
|
||||
if self.solid_lines_only or len(properties.linetype_pattern) < 2: # CONTINUOUS
|
||||
next_stage.draw_path(path, properties)
|
||||
return
|
||||
|
||||
renderer = linetypes.LineTypeRenderer(self.pattern(properties))
|
||||
vertices = path.flattening(self.config.max_flattening_distance, segments=16)
|
||||
next_stage.draw_solid_lines(
|
||||
[(Vec2(s), Vec2(e)) for s, e in renderer.line_segments(vertices)],
|
||||
properties,
|
||||
)
|
||||
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: list[BkPath2d],
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
self.next_stage.draw_filled_paths(paths, properties)
|
||||
|
||||
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
|
||||
self.next_stage.draw_filled_polygon(points, properties)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
|
||||
self.next_stage.draw_image(image_data, properties)
|
||||
|
||||
|
||||
class BackendStage2d(RenderStage2d):
|
||||
"""Send data to the output backend."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: BackendInterface,
|
||||
converter: Callable[[Properties], BackendProperties],
|
||||
):
|
||||
self.backend = backend
|
||||
self.converter = converter
|
||||
assert not hasattr(self, "next_stage"), "has to be the last render stage"
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: Properties) -> None:
|
||||
self.backend.draw_point(pos, self.converter(properties))
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
|
||||
self.backend.draw_line(start, end, self.converter(properties))
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
|
||||
) -> None:
|
||||
self.backend.draw_solid_lines(lines, self.converter(properties))
|
||||
|
||||
def draw_path(self, path: BkPath2d, properties: Properties):
|
||||
self.backend.draw_path(path, self.converter(properties))
|
||||
|
||||
def draw_filled_paths(
|
||||
self,
|
||||
paths: list[BkPath2d],
|
||||
properties: Properties,
|
||||
) -> None:
|
||||
self.backend.draw_filled_paths(paths, self.converter(properties))
|
||||
|
||||
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
|
||||
self.backend.draw_filled_polygon(points, self.converter(properties))
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
|
||||
self.backend.draw_image(image_data, self.converter(properties))
|
||||
|
||||
|
||||
def _mask_image(image_data: ImageData, outer_bounds: list[BkPoints2d]) -> None:
|
||||
"""Mask away the clipped parts of the image. The argument `outer_bounds` is only
|
||||
used for clip mode "remove_inside". The outer bounds can be composed of multiple
|
||||
parts. If `outer_bounds` is empty the image has no removed parts and is fully
|
||||
visible before applying the image clipping path.
|
||||
|
||||
Args:
|
||||
image_data:
|
||||
image_data.pixel_boundary: path contains the image clipping path
|
||||
image_data.remove_outside: defines the clipping mode (inside/outside)
|
||||
outer_bounds: countain the parts of the image which are __not__ removed by
|
||||
clipping through viewports or clipped block references
|
||||
e.g. an image without any removed parts has the outer bounds
|
||||
[(0, 0) (width, 0), (width, height), (0, height)]
|
||||
|
||||
"""
|
||||
clip_polygon = [(p.x, p.y) for p in image_data.pixel_boundary_path.vertices()]
|
||||
# create an empty image
|
||||
clipping_image = PIL.Image.new("L", image_data.image_size(), 0)
|
||||
# paint in the clipping path
|
||||
PIL.ImageDraw.ImageDraw(clipping_image).polygon(
|
||||
clip_polygon, outline=None, width=0, fill=1
|
||||
)
|
||||
clipping_mask = np.asarray(clipping_image)
|
||||
|
||||
if not image_data.remove_outside: # clip mode "remove_inside"
|
||||
if outer_bounds:
|
||||
# create a new empty image
|
||||
visible_image = PIL.Image.new("L", image_data.image_size(), 0)
|
||||
# paint in parts of the image which are still visible
|
||||
for boundary in outer_bounds:
|
||||
clip_polygon = [(p.x, p.y) for p in boundary.vertices()]
|
||||
PIL.ImageDraw.ImageDraw(visible_image).polygon(
|
||||
clip_polygon, outline=None, width=0, fill=1
|
||||
)
|
||||
# remove the clipping path
|
||||
clipping_mask = np.asarray(visible_image) - clipping_mask
|
||||
else:
|
||||
# create mask for fully visible image
|
||||
fully_visible_image_mask = np.full(
|
||||
clipping_mask.shape, fill_value=1, dtype=clipping_mask.dtype
|
||||
)
|
||||
# remove the clipping path
|
||||
clipping_mask = fully_visible_image_mask - clipping_mask
|
||||
image_data.image[:, :, 3] *= clipping_mask
|
||||
|
||||
|
||||
def _clip_image_polygon(
|
||||
clipping_portal: ClippingPortal, polygon_px: BkPoints2d, m: Matrix44
|
||||
) -> list[BkPoints2d]:
|
||||
original = [polygon_px]
|
||||
|
||||
# inverse matrix includes the transformation applied by the clipping portal
|
||||
inverse = clipping_portal.transform_matrix(m)
|
||||
try:
|
||||
inverse.inverse()
|
||||
except ZeroDivisionError:
|
||||
# inverse transformation from WCS to pixel coordinates is not possible
|
||||
return original
|
||||
|
||||
# transform image coordinates to WCS coordinates
|
||||
polygon = polygon_px.clone()
|
||||
polygon.transform_inplace(m)
|
||||
|
||||
clipped_polygons = clipping_portal.clip_polygon(polygon)
|
||||
if (len(clipped_polygons) == 1) and (clipped_polygons[0] is polygon):
|
||||
# this shows the caller that the image boundary path wasn't clipped
|
||||
return original
|
||||
# transform WCS coordinates to image coordinates
|
||||
for polygon in clipped_polygons:
|
||||
polygon.transform_inplace(inverse)
|
||||
return clipped_polygons # in image coordinates!
|
||||
|
||||
|
||||
def invert_color(color: Color) -> Color:
|
||||
r, g, b = RGB.from_hex(color)
|
||||
return RGB(255 - r, 255 - g, 255 - b).to_hex()
|
||||
|
||||
|
||||
def swap_bw(color: str) -> Color:
|
||||
color = color.lower()
|
||||
if color == "#000000":
|
||||
return "#ffffff"
|
||||
if color == "#ffffff":
|
||||
return "#000000"
|
||||
return color
|
||||
|
||||
|
||||
def color_to_monochrome(color: Color, scale: float = 1.0, offset: float = 0.0) -> Color:
|
||||
lum = RGB.from_hex(color).luminance * scale + offset
|
||||
if lum < 0.0:
|
||||
lum = 0.0
|
||||
elif lum > 1.0:
|
||||
lum = 1.0
|
||||
gray = round(lum * 255)
|
||||
return RGB(gray, gray, gray).to_hex()
|
||||
|
||||
|
||||
def apply_color_policy(color: Color, policy: ColorPolicy, custom_color: Color) -> Color:
|
||||
alpha = color[7:9]
|
||||
color = color[:7]
|
||||
if policy == ColorPolicy.COLOR_SWAP_BW:
|
||||
color = swap_bw(color)
|
||||
elif policy == ColorPolicy.COLOR_NEGATIVE:
|
||||
color = invert_color(color)
|
||||
elif policy == ColorPolicy.MONOCHROME_DARK_BG: # [0.3, 1.0]
|
||||
color = color_to_monochrome(color, scale=0.7, offset=0.3)
|
||||
elif policy == ColorPolicy.MONOCHROME_LIGHT_BG: # [0.0, 0.7]
|
||||
color = color_to_monochrome(color, scale=0.7, offset=0.0)
|
||||
elif policy == ColorPolicy.MONOCHROME: # [0.0, 1.0]
|
||||
color = color_to_monochrome(color)
|
||||
elif policy == ColorPolicy.BLACK:
|
||||
color = "#000000"
|
||||
elif policy == ColorPolicy.WHITE:
|
||||
color = "#ffffff"
|
||||
elif policy == ColorPolicy.CUSTOM:
|
||||
fg = custom_color
|
||||
color = fg[:7]
|
||||
alpha = fg[7:9]
|
||||
return color + alpha
|
||||
|
||||
|
||||
def filter_vp_entities(
|
||||
msp: Layout,
|
||||
limits: Sequence[float],
|
||||
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
|
||||
) -> Iterator[DXFGraphic]:
|
||||
"""Yields all DXF entities that need to be processed by the given viewport
|
||||
`limits`. The entities may be partially of even complete outside the viewport.
|
||||
By passing the bounding box cache of the modelspace entities,
|
||||
the function can filter entities outside the viewport to speed up rendering
|
||||
time.
|
||||
|
||||
There are two processing modes for the `bbox_cache`:
|
||||
|
||||
1. The `bbox_cache` is``None``: all entities must be processed,
|
||||
pass through mode
|
||||
2. If the `bbox_cache` is given but does not contain an entity,
|
||||
the bounding box is computed and added to the cache.
|
||||
Even passing in an empty cache can speed up rendering time when
|
||||
multiple viewports need to be processed.
|
||||
|
||||
Args:
|
||||
msp: modelspace layout
|
||||
limits: modelspace limits of the viewport, as tuple (min_x, min_y, max_x, max_y)
|
||||
bbox_cache: the bounding box cache of the modelspace entities
|
||||
|
||||
"""
|
||||
|
||||
# WARNING: this works only with top-view viewports
|
||||
# The current state of the drawing add-on supports only top-view viewports!
|
||||
def is_visible(e):
|
||||
entity_bbox = bbox_cache.get(e)
|
||||
if entity_bbox is None:
|
||||
# compute and add bounding box
|
||||
entity_bbox = ezdxf.bbox.extents((e,), fast=True, cache=bbox_cache)
|
||||
if not entity_bbox.has_data:
|
||||
return True
|
||||
# Check for separating axis:
|
||||
if min_x >= entity_bbox.extmax.x:
|
||||
return False
|
||||
if max_x <= entity_bbox.extmin.x:
|
||||
return False
|
||||
if min_y >= entity_bbox.extmax.y:
|
||||
return False
|
||||
if max_y <= entity_bbox.extmin.y:
|
||||
return False
|
||||
return True
|
||||
|
||||
if bbox_cache is None: # pass through all entities
|
||||
yield from msp
|
||||
return
|
||||
|
||||
min_x, min_y, max_x, max_y = limits
|
||||
if not bbox_cache.has_data:
|
||||
# fill cache at once
|
||||
ezdxf.bbox.extents(msp, fast=True, cache=bbox_cache)
|
||||
|
||||
for entity in msp:
|
||||
if is_visible(entity):
|
||||
yield entity
|
||||
|
||||
|
||||
def prepare_string_for_rendering(text: str, dxftype: str) -> str:
|
||||
assert "\n" not in text, "not a single line of text"
|
||||
if dxftype in {"TEXT", "ATTRIB", "ATTDEF"}:
|
||||
text = replace_non_printable_characters(text, replacement="?")
|
||||
text = text.replace("\t", "?")
|
||||
elif dxftype == "MTEXT":
|
||||
text = replace_non_printable_characters(text, replacement="▯")
|
||||
text = text.replace("\t", " ")
|
||||
else:
|
||||
raise TypeError(dxftype)
|
||||
return text
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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
|
||||
314
.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/pyqt.py
Normal file
314
.venv/lib/python3.12/site-packages/ezdxf/addons/drawing/pyqt.py
Normal 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())
|
||||
@@ -0,0 +1,623 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2020-2023, Matthew Broadway
|
||||
# License: MIT License
|
||||
# mypy: ignore_errors=True
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence, Set, Optional
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
|
||||
from ezdxf.addons.xqt import QtWidgets as qw, QtCore as qc, QtGui as qg
|
||||
from ezdxf.addons.xqt import Slot, QAction, Signal
|
||||
|
||||
import ezdxf
|
||||
import ezdxf.bbox
|
||||
from ezdxf import recover
|
||||
from ezdxf.addons import odafc
|
||||
from ezdxf.addons.drawing import Frontend, RenderContext
|
||||
from ezdxf.addons.drawing.config import Configuration
|
||||
|
||||
from ezdxf.addons.drawing.properties import (
|
||||
is_dark_color,
|
||||
set_layers_state,
|
||||
LayerProperties,
|
||||
)
|
||||
from ezdxf.addons.drawing.pyqt import (
|
||||
_get_x_scale,
|
||||
PyQtBackend,
|
||||
CorrespondingDXFEntity,
|
||||
CorrespondingDXFParentStack,
|
||||
)
|
||||
from ezdxf.audit import Auditor
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.entities import DXFGraphic, DXFEntity
|
||||
from ezdxf.layouts import Layout
|
||||
from ezdxf.lldxf.const import DXFStructureError
|
||||
|
||||
|
||||
class CADGraphicsView(qw.QGraphicsView):
|
||||
closing = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
view_buffer: float = 0.2,
|
||||
zoom_per_scroll_notch: float = 0.2,
|
||||
loading_overlay: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
self._zoom = 1.0
|
||||
self._default_zoom = 1.0
|
||||
self._zoom_limits = (0.5, 100)
|
||||
self._zoom_per_scroll_notch = zoom_per_scroll_notch
|
||||
self._view_buffer = view_buffer
|
||||
self._loading_overlay = loading_overlay
|
||||
self._is_loading = False
|
||||
|
||||
self.setTransformationAnchor(qw.QGraphicsView.AnchorUnderMouse)
|
||||
self.setResizeAnchor(qw.QGraphicsView.AnchorUnderMouse)
|
||||
self.setVerticalScrollBarPolicy(qc.Qt.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(qc.Qt.ScrollBarAlwaysOff)
|
||||
self.setDragMode(qw.QGraphicsView.ScrollHandDrag)
|
||||
self.setFrameShape(qw.QFrame.NoFrame)
|
||||
self.setRenderHints(
|
||||
qg.QPainter.Antialiasing
|
||||
| qg.QPainter.TextAntialiasing
|
||||
| qg.QPainter.SmoothPixmapTransform
|
||||
)
|
||||
|
||||
self.setScene(qw.QGraphicsScene())
|
||||
self.scale(1, -1) # so that +y is up
|
||||
|
||||
def closeEvent(self, event: qg.QCloseEvent) -> None:
|
||||
super().closeEvent(event)
|
||||
self.closing.emit()
|
||||
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
def begin_loading(self):
|
||||
self._is_loading = True
|
||||
self.scene().invalidate(qc.QRectF(), qw.QGraphicsScene.AllLayers)
|
||||
qw.QApplication.processEvents()
|
||||
|
||||
def end_loading(self, new_scene: qw.QGraphicsScene):
|
||||
self.setScene(new_scene)
|
||||
self._is_loading = False
|
||||
self.buffer_scene_rect()
|
||||
self.scene().invalidate(qc.QRectF(), qw.QGraphicsScene.AllLayers)
|
||||
|
||||
def buffer_scene_rect(self):
|
||||
scene = self.scene()
|
||||
r = scene.sceneRect()
|
||||
bx, by = (
|
||||
r.width() * self._view_buffer / 2,
|
||||
r.height() * self._view_buffer / 2,
|
||||
)
|
||||
scene.setSceneRect(r.adjusted(-bx, -by, bx, by))
|
||||
|
||||
def fit_to_scene(self):
|
||||
self.fitInView(self.sceneRect(), qc.Qt.KeepAspectRatio)
|
||||
self._default_zoom = _get_x_scale(self.transform())
|
||||
self._zoom = 1
|
||||
|
||||
def _get_zoom_amount(self) -> float:
|
||||
return _get_x_scale(self.transform()) / self._default_zoom
|
||||
|
||||
def wheelEvent(self, event: qg.QWheelEvent) -> None:
|
||||
# dividing by 120 gets number of notches on a typical scroll wheel.
|
||||
# See QWheelEvent documentation
|
||||
delta_notches = event.angleDelta().y() / 120
|
||||
direction = math.copysign(1, delta_notches)
|
||||
factor = (1.0 + self._zoom_per_scroll_notch * direction) ** abs(delta_notches)
|
||||
resulting_zoom = self._zoom * factor
|
||||
if resulting_zoom < self._zoom_limits[0]:
|
||||
factor = self._zoom_limits[0] / self._zoom
|
||||
elif resulting_zoom > self._zoom_limits[1]:
|
||||
factor = self._zoom_limits[1] / self._zoom
|
||||
self.scale(factor, factor)
|
||||
self._zoom *= factor
|
||||
|
||||
def save_view(self) -> SavedView:
|
||||
return SavedView(
|
||||
self.transform(),
|
||||
self._default_zoom,
|
||||
self._zoom,
|
||||
self.horizontalScrollBar().value(),
|
||||
self.verticalScrollBar().value(),
|
||||
)
|
||||
|
||||
def restore_view(self, view: SavedView):
|
||||
self.setTransform(view.transform)
|
||||
self._default_zoom = view.default_zoom
|
||||
self._zoom = view.zoom
|
||||
self.horizontalScrollBar().setValue(view.x)
|
||||
self.verticalScrollBar().setValue(view.y)
|
||||
|
||||
def drawForeground(self, painter: qg.QPainter, rect: qc.QRectF) -> None:
|
||||
if self._is_loading and self._loading_overlay:
|
||||
painter.save()
|
||||
painter.fillRect(rect, qg.QColor("#aa000000"))
|
||||
painter.setWorldMatrixEnabled(False)
|
||||
r = self.viewport().rect()
|
||||
painter.setBrush(qc.Qt.NoBrush)
|
||||
painter.setPen(qc.Qt.white)
|
||||
painter.drawText(r.center(), "Loading...")
|
||||
painter.restore()
|
||||
|
||||
|
||||
class SavedView:
|
||||
def __init__(
|
||||
self, transform: qg.QTransform, default_zoom: float, zoom: float, x: int, y: int
|
||||
):
|
||||
self.transform = transform
|
||||
self.default_zoom = default_zoom
|
||||
self.zoom = zoom
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
|
||||
class CADGraphicsViewWithOverlay(CADGraphicsView):
|
||||
mouse_moved = Signal(qc.QPointF)
|
||||
element_hovered = Signal(object, int)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._selected_items: list[qw.QGraphicsItem] = []
|
||||
self._selected_index = None
|
||||
self._mark_selection = True
|
||||
|
||||
@property
|
||||
def current_hovered_element(self) -> Optional[DXFEntity]:
|
||||
if self._selected_items:
|
||||
graphics_item = self._selected_items[self._selected_index]
|
||||
dxf_entity = graphics_item.data(CorrespondingDXFEntity)
|
||||
return dxf_entity
|
||||
else:
|
||||
return None
|
||||
|
||||
def clear(self):
|
||||
super().clear()
|
||||
self._selected_items = None
|
||||
self._selected_index = None
|
||||
|
||||
def begin_loading(self):
|
||||
self.clear()
|
||||
super().begin_loading()
|
||||
|
||||
def drawForeground(self, painter: qg.QPainter, rect: qc.QRectF) -> None:
|
||||
super().drawForeground(painter, rect)
|
||||
if self._selected_items and self._mark_selection:
|
||||
item = self._selected_items[self._selected_index]
|
||||
r = item.sceneTransform().mapRect(item.boundingRect())
|
||||
painter.fillRect(r, qg.QColor(0, 255, 0, 100))
|
||||
|
||||
def mouseMoveEvent(self, event: qg.QMouseEvent) -> None:
|
||||
super().mouseMoveEvent(event)
|
||||
pos = self.mapToScene(event.pos())
|
||||
self.mouse_moved.emit(pos)
|
||||
selected_items = self.scene().items(pos)
|
||||
if selected_items != self._selected_items:
|
||||
self._selected_items = selected_items
|
||||
self._selected_index = 0 if self._selected_items else None
|
||||
self._emit_selected()
|
||||
|
||||
def mouseReleaseEvent(self, event: qg.QMouseEvent) -> None:
|
||||
super().mouseReleaseEvent(event)
|
||||
if event.button() == qc.Qt.LeftButton and self._selected_items:
|
||||
self._selected_index = (self._selected_index + 1) % len(
|
||||
self._selected_items
|
||||
)
|
||||
self._emit_selected()
|
||||
|
||||
def _emit_selected(self):
|
||||
self.element_hovered.emit(self._selected_items, self._selected_index)
|
||||
self.scene().invalidate(self.sceneRect(), qw.QGraphicsScene.ForegroundLayer)
|
||||
|
||||
def toggle_selection_marker(self):
|
||||
self._mark_selection = not self._mark_selection
|
||||
|
||||
|
||||
class CADWidget(qw.QWidget):
|
||||
def __init__(self, view: CADGraphicsView, config: Configuration = Configuration()):
|
||||
super().__init__()
|
||||
layout = qw.QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(view)
|
||||
self.setLayout(layout)
|
||||
self._view = view
|
||||
self._view.closing.connect(self.close)
|
||||
self._config = config
|
||||
self._bbox_cache = ezdxf.bbox.Cache()
|
||||
self._doc: Drawing = None # type: ignore
|
||||
self._render_context: RenderContext = None # type: ignore
|
||||
self._visible_layers: set[str] = set()
|
||||
self._current_layout: str = "Model"
|
||||
self._reset_backend()
|
||||
|
||||
def _reset_backend(self):
|
||||
# clear caches
|
||||
self._backend = PyQtBackend()
|
||||
|
||||
@property
|
||||
def doc(self) -> Drawing:
|
||||
return self._doc
|
||||
|
||||
@property
|
||||
def view(self) -> CADGraphicsView:
|
||||
return self._view
|
||||
|
||||
@property
|
||||
def render_context(self) -> RenderContext:
|
||||
return self._render_context
|
||||
|
||||
@property
|
||||
def current_layout(self) -> str:
|
||||
return self._current_layout
|
||||
|
||||
def set_document(
|
||||
self,
|
||||
document: Drawing,
|
||||
*,
|
||||
layout: str = "Model",
|
||||
draw: bool = True,
|
||||
):
|
||||
self._doc = document
|
||||
# initialize bounding box cache for faste paperspace drawing
|
||||
self._bbox_cache = ezdxf.bbox.Cache()
|
||||
self._render_context = self._make_render_context(document)
|
||||
self._reset_backend()
|
||||
self._visible_layers = set()
|
||||
self._current_layout = None
|
||||
if draw:
|
||||
self.draw_layout(layout)
|
||||
|
||||
def set_visible_layers(self, layers: Set[str]) -> None:
|
||||
self._visible_layers = layers
|
||||
self.draw_layout(self._current_layout, reset_view=False)
|
||||
|
||||
def _make_render_context(self, doc: Drawing) -> RenderContext:
|
||||
def update_layers_state(layers: Sequence[LayerProperties]):
|
||||
if self._visible_layers:
|
||||
set_layers_state(layers, self._visible_layers, state=True)
|
||||
|
||||
render_context = RenderContext(doc)
|
||||
render_context.set_layer_properties_override(update_layers_state)
|
||||
return render_context
|
||||
|
||||
def draw_layout(
|
||||
self,
|
||||
layout_name: str,
|
||||
reset_view: bool = True,
|
||||
):
|
||||
self._current_layout = layout_name
|
||||
self._view.begin_loading()
|
||||
new_scene = qw.QGraphicsScene()
|
||||
self._backend.set_scene(new_scene)
|
||||
layout = self._doc.layout(layout_name)
|
||||
self._update_render_context(layout)
|
||||
try:
|
||||
self._create_frontend().draw_layout(layout)
|
||||
finally:
|
||||
self._backend.finalize()
|
||||
self._view.end_loading(new_scene)
|
||||
self._view.buffer_scene_rect()
|
||||
if reset_view:
|
||||
self._view.fit_to_scene()
|
||||
|
||||
def _create_frontend(self) -> Frontend:
|
||||
return Frontend(
|
||||
ctx=self._render_context,
|
||||
out=self._backend,
|
||||
config=self._config,
|
||||
bbox_cache=self._bbox_cache,
|
||||
)
|
||||
|
||||
def _update_render_context(self, layout: Layout) -> None:
|
||||
assert self._render_context is not None
|
||||
self._render_context.set_current_layout(layout)
|
||||
|
||||
|
||||
class CADViewer(qw.QMainWindow):
|
||||
def __init__(self, cad: Optional[CADWidget] = None):
|
||||
super().__init__()
|
||||
self._doc: Optional[Drawing] = None
|
||||
if cad is None:
|
||||
self._cad = CADWidget(CADGraphicsViewWithOverlay(), config=Configuration())
|
||||
else:
|
||||
self._cad = cad
|
||||
self._view = self._cad.view
|
||||
|
||||
if isinstance(self._view, CADGraphicsViewWithOverlay):
|
||||
self._view.element_hovered.connect(self._on_element_hovered)
|
||||
self._view.mouse_moved.connect(self._on_mouse_moved)
|
||||
|
||||
menu = self.menuBar()
|
||||
select_doc_action = QAction("Select Document", self)
|
||||
select_doc_action.triggered.connect(self._select_doc)
|
||||
menu.addAction(select_doc_action)
|
||||
self.select_layout_menu = menu.addMenu("Select Layout")
|
||||
|
||||
toggle_sidebar_action = QAction("Toggle Sidebar", self)
|
||||
toggle_sidebar_action.triggered.connect(self._toggle_sidebar)
|
||||
menu.addAction(toggle_sidebar_action)
|
||||
|
||||
toggle_selection_marker_action = QAction("Toggle Entity Marker", self)
|
||||
toggle_selection_marker_action.triggered.connect(self._toggle_selection_marker)
|
||||
menu.addAction(toggle_selection_marker_action)
|
||||
|
||||
self.reload_menu = menu.addMenu("Reload")
|
||||
reload_action = QAction("Reload", self)
|
||||
reload_action.setShortcut(qg.QKeySequence("F5"))
|
||||
reload_action.triggered.connect(self._reload)
|
||||
self.reload_menu.addAction(reload_action)
|
||||
self.keep_view_action = QAction("Keep View", self)
|
||||
self.keep_view_action.setCheckable(True)
|
||||
self.keep_view_action.setChecked(True)
|
||||
self.reload_menu.addAction(self.keep_view_action)
|
||||
watch_action = QAction("Watch", self)
|
||||
watch_action.setCheckable(True)
|
||||
watch_action.toggled.connect(self._toggle_watch)
|
||||
self.reload_menu.addAction(watch_action)
|
||||
self._watch_timer = qc.QTimer()
|
||||
self._watch_timer.setInterval(50)
|
||||
self._watch_timer.timeout.connect(self._check_watch)
|
||||
self._watch_mtime = None
|
||||
|
||||
self.sidebar = qw.QSplitter(qc.Qt.Vertical)
|
||||
self.layers = qw.QListWidget()
|
||||
self.layers.setStyleSheet(
|
||||
"QListWidget {font-size: 12pt;} "
|
||||
"QCheckBox {font-size: 12pt; padding-left: 5px;}"
|
||||
)
|
||||
self.sidebar.addWidget(self.layers)
|
||||
info_container = qw.QWidget()
|
||||
info_layout = qw.QVBoxLayout()
|
||||
info_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.selected_info = qw.QPlainTextEdit()
|
||||
self.selected_info.setReadOnly(True)
|
||||
info_layout.addWidget(self.selected_info)
|
||||
self.mouse_pos = qw.QLabel()
|
||||
info_layout.addWidget(self.mouse_pos)
|
||||
info_container.setLayout(info_layout)
|
||||
self.sidebar.addWidget(info_container)
|
||||
|
||||
container = qw.QSplitter()
|
||||
self.setCentralWidget(container)
|
||||
container.addWidget(self._cad)
|
||||
container.addWidget(self.sidebar)
|
||||
container.setCollapsible(0, False)
|
||||
container.setCollapsible(1, True)
|
||||
w = container.width()
|
||||
container.setSizes([int(3 * w / 4), int(w / 4)])
|
||||
self.setWindowTitle("CAD Viewer")
|
||||
self.resize(1600, 900)
|
||||
self.show()
|
||||
|
||||
@staticmethod
|
||||
def from_config(config: Configuration) -> CADViewer:
|
||||
return CADViewer(cad=CADWidget(CADGraphicsViewWithOverlay(), config=config))
|
||||
|
||||
def _create_cad_widget(self):
|
||||
self._view = CADGraphicsViewWithOverlay()
|
||||
self._cad = CADWidget(self._view)
|
||||
|
||||
def load_file(self, path: str, layout: str = "Model"):
|
||||
try:
|
||||
if os.path.splitext(path)[1].lower() == ".dwg":
|
||||
doc = odafc.readfile(path)
|
||||
auditor = doc.audit()
|
||||
else:
|
||||
try:
|
||||
doc = ezdxf.readfile(path)
|
||||
except ezdxf.DXFError:
|
||||
doc, auditor = recover.readfile(path)
|
||||
else:
|
||||
auditor = doc.audit()
|
||||
self.set_document(doc, auditor, layout=layout)
|
||||
except IOError as e:
|
||||
qw.QMessageBox.critical(self, "Loading Error", str(e))
|
||||
except DXFStructureError as e:
|
||||
qw.QMessageBox.critical(
|
||||
self,
|
||||
"DXF Structure Error",
|
||||
f'Invalid DXF file "{path}": {str(e)}',
|
||||
)
|
||||
|
||||
def _select_doc(self):
|
||||
path, _ = qw.QFileDialog.getOpenFileName(
|
||||
self,
|
||||
caption="Select CAD Document",
|
||||
filter="CAD Documents (*.dxf *.DXF *.dwg *.DWG)",
|
||||
)
|
||||
if path:
|
||||
self.load_file(path)
|
||||
|
||||
def set_document(
|
||||
self,
|
||||
document: Drawing,
|
||||
auditor: Auditor,
|
||||
*,
|
||||
layout: str = "Model",
|
||||
draw: bool = True,
|
||||
):
|
||||
error_count = len(auditor.errors)
|
||||
if error_count > 0:
|
||||
ret = qw.QMessageBox.question(
|
||||
self,
|
||||
"Found DXF Errors",
|
||||
f'Found {error_count} errors in file "{document.filename}"\n'
|
||||
f"Load file anyway? ",
|
||||
)
|
||||
if ret == qw.QMessageBox.No:
|
||||
auditor.print_error_report(auditor.errors)
|
||||
return
|
||||
|
||||
if document.filename:
|
||||
try:
|
||||
self._watch_mtime = os.stat(document.filename).st_mtime
|
||||
except OSError:
|
||||
self._watch_mtime = None
|
||||
else:
|
||||
self._watch_mtime = None
|
||||
self._cad.set_document(document, layout=layout, draw=draw)
|
||||
self._doc = document
|
||||
self._populate_layouts()
|
||||
self._populate_layer_list()
|
||||
self.setWindowTitle("CAD Viewer - " + str(document.filename))
|
||||
|
||||
def _populate_layer_list(self):
|
||||
self.layers.blockSignals(True)
|
||||
self.layers.clear()
|
||||
for layer in self._cad.render_context.layers.values():
|
||||
name = layer.layer
|
||||
item = qw.QListWidgetItem()
|
||||
self.layers.addItem(item)
|
||||
checkbox = qw.QCheckBox(name)
|
||||
checkbox.setCheckState(
|
||||
qc.Qt.Checked if layer.is_visible else qc.Qt.Unchecked
|
||||
)
|
||||
checkbox.stateChanged.connect(self._layers_updated)
|
||||
text_color = "#FFFFFF" if is_dark_color(layer.color, 0.4) else "#000000"
|
||||
checkbox.setStyleSheet(
|
||||
f"color: {text_color}; background-color: {layer.color}"
|
||||
)
|
||||
self.layers.setItemWidget(item, checkbox)
|
||||
self.layers.blockSignals(False)
|
||||
|
||||
def _populate_layouts(self):
|
||||
def draw_layout(name: str):
|
||||
def run():
|
||||
self.draw_layout(name, reset_view=True)
|
||||
|
||||
return run
|
||||
|
||||
self.select_layout_menu.clear()
|
||||
for layout_name in self._cad.doc.layout_names_in_taborder():
|
||||
action = QAction(layout_name, self)
|
||||
action.triggered.connect(draw_layout(layout_name))
|
||||
self.select_layout_menu.addAction(action)
|
||||
|
||||
def draw_layout(
|
||||
self,
|
||||
layout_name: str,
|
||||
reset_view: bool = True,
|
||||
):
|
||||
print(f"drawing {layout_name}")
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
self._cad.draw_layout(layout_name, reset_view=reset_view)
|
||||
duration = time.perf_counter() - start
|
||||
print(f"took {duration:.4f} seconds")
|
||||
except DXFStructureError as e:
|
||||
qw.QMessageBox.critical(
|
||||
self,
|
||||
"DXF Structure Error",
|
||||
f'Abort rendering of layout "{layout_name}": {str(e)}',
|
||||
)
|
||||
|
||||
def resizeEvent(self, event: qg.QResizeEvent) -> None:
|
||||
self._view.fit_to_scene()
|
||||
|
||||
def _layer_checkboxes(self) -> Iterable[tuple[int, qw.QCheckBox]]:
|
||||
for i in range(self.layers.count()):
|
||||
item = self.layers.itemWidget(self.layers.item(i))
|
||||
yield i, item # type: ignore
|
||||
|
||||
@Slot(int) # type: ignore
|
||||
def _layers_updated(self, item_state: qc.Qt.CheckState):
|
||||
shift_held = qw.QApplication.keyboardModifiers() & qc.Qt.ShiftModifier
|
||||
if shift_held:
|
||||
for i, item in self._layer_checkboxes():
|
||||
item.blockSignals(True)
|
||||
item.setCheckState(item_state)
|
||||
item.blockSignals(False)
|
||||
|
||||
visible_layers = set()
|
||||
for i, layer in self._layer_checkboxes():
|
||||
if layer.checkState() == qc.Qt.Checked:
|
||||
visible_layers.add(layer.text())
|
||||
self._cad.set_visible_layers(visible_layers)
|
||||
|
||||
@Slot()
|
||||
def _toggle_sidebar(self):
|
||||
self.sidebar.setHidden(not self.sidebar.isHidden())
|
||||
|
||||
@Slot()
|
||||
def _toggle_selection_marker(self):
|
||||
self._view.toggle_selection_marker()
|
||||
|
||||
@Slot()
|
||||
def _reload(self):
|
||||
if self._cad.doc is not None and self._cad.doc.filename:
|
||||
keep_view = self.keep_view_action.isChecked()
|
||||
view = self._view.save_view() if keep_view else None
|
||||
self.load_file(self._cad.doc.filename, layout=self._cad.current_layout)
|
||||
if keep_view:
|
||||
self._view.restore_view(view)
|
||||
|
||||
@Slot()
|
||||
def _toggle_watch(self):
|
||||
if self._watch_timer.isActive():
|
||||
self._watch_timer.stop()
|
||||
else:
|
||||
self._watch_timer.start()
|
||||
|
||||
@Slot()
|
||||
def _check_watch(self):
|
||||
if self._watch_mtime is None or self._cad.doc is None:
|
||||
return
|
||||
filename = self._cad.doc.filename
|
||||
if filename:
|
||||
try:
|
||||
mtime = os.stat(filename).st_mtime
|
||||
except OSError:
|
||||
return
|
||||
if mtime != self._watch_mtime:
|
||||
self._reload()
|
||||
|
||||
@Slot(qc.QPointF)
|
||||
def _on_mouse_moved(self, mouse_pos: qc.QPointF):
|
||||
self.mouse_pos.setText(
|
||||
f"mouse position: {mouse_pos.x():.4f}, {mouse_pos.y():.4f}\n"
|
||||
)
|
||||
|
||||
@Slot(object, int)
|
||||
def _on_element_hovered(self, elements: list[qw.QGraphicsItem], index: int):
|
||||
if not elements:
|
||||
text = "No element selected"
|
||||
else:
|
||||
text = f"Selected: {index + 1} / {len(elements)} (click to cycle)\n"
|
||||
element = elements[index]
|
||||
dxf_entity: DXFGraphic | str | None = element.data(CorrespondingDXFEntity)
|
||||
if isinstance(dxf_entity, str):
|
||||
dxf_entity = self.load_dxf_entity(dxf_entity)
|
||||
if dxf_entity is None:
|
||||
text += "No data"
|
||||
else:
|
||||
text += (
|
||||
f"Selected Entity: {dxf_entity}\n"
|
||||
f"Layer: {dxf_entity.dxf.layer}\n\nDXF Attributes:\n"
|
||||
)
|
||||
text += _entity_attribs_string(dxf_entity)
|
||||
|
||||
dxf_parent_stack = element.data(CorrespondingDXFParentStack)
|
||||
if dxf_parent_stack:
|
||||
text += "\nParents:\n"
|
||||
for entity in reversed(dxf_parent_stack):
|
||||
text += f"- {entity}\n"
|
||||
text += _entity_attribs_string(entity, indent=" ")
|
||||
self.selected_info.setPlainText(text)
|
||||
|
||||
def load_dxf_entity(self, entity_handle: str) -> DXFGraphic | None:
|
||||
if self._doc is not None:
|
||||
return self._doc.entitydb.get(entity_handle)
|
||||
return None
|
||||
|
||||
|
||||
def _entity_attribs_string(dxf_entity: DXFGraphic, indent: str = "") -> str:
|
||||
text = ""
|
||||
for key, value in dxf_entity.dxf.all_existing_dxf_attribs().items():
|
||||
text += f"{indent}- {key}: {value}\n"
|
||||
return text
|
||||
@@ -0,0 +1,447 @@
|
||||
# Copyright (c) 2023-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Iterable,
|
||||
Iterator,
|
||||
Sequence,
|
||||
Callable,
|
||||
Optional,
|
||||
NamedTuple,
|
||||
)
|
||||
from typing_extensions import Self, TypeAlias
|
||||
import copy
|
||||
import abc
|
||||
|
||||
from ezdxf.math import BoundingBox2d, Matrix44, Vec2, UVec
|
||||
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d, EmptyShapeError
|
||||
from ezdxf.tools import take2
|
||||
from ezdxf.tools.clipping_portal import ClippingRect
|
||||
|
||||
from .backend import BackendInterface, ImageData
|
||||
from .config import Configuration
|
||||
from .properties import BackendProperties
|
||||
from .type_hints import Color
|
||||
|
||||
|
||||
class DataRecord(abc.ABC):
|
||||
def __init__(self) -> None:
|
||||
self.property_hash: int = 0
|
||||
self.handle: str = ""
|
||||
|
||||
@abc.abstractmethod
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
...
|
||||
|
||||
|
||||
class PointsRecord(DataRecord):
|
||||
# n=1 point; n=2 line; n>2 filled polygon
|
||||
def __init__(self, points: NumpyPoints2d) -> None:
|
||||
super().__init__()
|
||||
self.points: NumpyPoints2d = points
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
try:
|
||||
return self.points.bbox()
|
||||
except EmptyShapeError:
|
||||
pass
|
||||
return BoundingBox2d()
|
||||
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
self.points.transform_inplace(m)
|
||||
|
||||
|
||||
class SolidLinesRecord(DataRecord):
|
||||
def __init__(self, lines: NumpyPoints2d) -> None:
|
||||
super().__init__()
|
||||
self.lines: NumpyPoints2d = lines
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
try:
|
||||
return self.lines.bbox()
|
||||
except EmptyShapeError:
|
||||
pass
|
||||
return BoundingBox2d()
|
||||
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
self.lines.transform_inplace(m)
|
||||
|
||||
|
||||
class PathRecord(DataRecord):
|
||||
def __init__(self, path: NumpyPath2d) -> None:
|
||||
super().__init__()
|
||||
self.path: NumpyPath2d = path
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
try:
|
||||
return self.path.bbox()
|
||||
except EmptyShapeError:
|
||||
pass
|
||||
return BoundingBox2d()
|
||||
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
self.path.transform_inplace(m)
|
||||
|
||||
|
||||
class FilledPathsRecord(DataRecord):
|
||||
def __init__(self, paths: Sequence[NumpyPath2d]) -> None:
|
||||
super().__init__()
|
||||
self.paths: Sequence[NumpyPath2d] = paths
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
bbox = BoundingBox2d()
|
||||
for path in self.paths:
|
||||
if len(path):
|
||||
bbox.extend(path.extents())
|
||||
return bbox
|
||||
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
for path in self.paths:
|
||||
path.transform_inplace(m)
|
||||
|
||||
|
||||
class ImageRecord(DataRecord):
|
||||
def __init__(self, boundary: NumpyPoints2d, image_data: ImageData) -> None:
|
||||
super().__init__()
|
||||
self.boundary: NumpyPoints2d = boundary
|
||||
self.image_data: ImageData = image_data
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
try:
|
||||
return self.boundary.bbox()
|
||||
except EmptyShapeError:
|
||||
pass
|
||||
return BoundingBox2d()
|
||||
|
||||
def transform_inplace(self, m: Matrix44) -> None:
|
||||
self.boundary.transform_inplace(m)
|
||||
self.image_data.transform @= m
|
||||
|
||||
|
||||
class Recorder(BackendInterface):
|
||||
"""Records the output of the Frontend class."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.config = Configuration()
|
||||
self.background: Color = "#000000"
|
||||
self.records: list[DataRecord] = []
|
||||
self.properties: dict[int, BackendProperties] = dict()
|
||||
|
||||
def player(self) -> Player:
|
||||
"""Returns a :class:`Player` instance with the original recordings! Make a copy
|
||||
of this player to protect the original recordings from being modified::
|
||||
|
||||
safe_player = recorder.player().copy()
|
||||
|
||||
"""
|
||||
player = Player()
|
||||
player.config = self.config
|
||||
player.background = self.background
|
||||
player.records = self.records
|
||||
player.properties = self.properties
|
||||
player.has_shared_recordings = True
|
||||
return player
|
||||
|
||||
def configure(self, config: Configuration) -> None:
|
||||
self.config = config
|
||||
|
||||
def set_background(self, color: Color) -> None:
|
||||
self.background = color
|
||||
|
||||
def store(self, record: DataRecord, properties: BackendProperties) -> None:
|
||||
# exclude top-level entity handle to reduce the variance:
|
||||
# color, lineweight, layer, pen
|
||||
prop_hash = hash(properties[:4])
|
||||
record.property_hash = prop_hash
|
||||
record.handle = properties.handle
|
||||
self.records.append(record)
|
||||
self.properties[prop_hash] = properties
|
||||
|
||||
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
|
||||
self.store(PointsRecord(NumpyPoints2d((pos,))), properties)
|
||||
|
||||
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
|
||||
self.store(PointsRecord(NumpyPoints2d((start, end))), properties)
|
||||
|
||||
def draw_solid_lines(
|
||||
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
|
||||
) -> None:
|
||||
def flatten() -> Iterator[Vec2]:
|
||||
for s, e in lines:
|
||||
yield s
|
||||
yield e
|
||||
|
||||
self.store(SolidLinesRecord(NumpyPoints2d(flatten())), properties)
|
||||
|
||||
def draw_path(self, path: NumpyPath2d, properties: BackendProperties) -> None:
|
||||
assert isinstance(path, NumpyPath2d)
|
||||
self.store(PathRecord(path), properties)
|
||||
|
||||
def draw_filled_polygon(
|
||||
self, points: NumpyPoints2d, properties: BackendProperties
|
||||
) -> None:
|
||||
assert isinstance(points, NumpyPoints2d)
|
||||
self.store(PointsRecord(points), properties)
|
||||
|
||||
def draw_filled_paths(
|
||||
self, paths: Iterable[NumpyPath2d], properties: BackendProperties
|
||||
) -> None:
|
||||
paths = tuple(paths)
|
||||
if len(paths) == 0:
|
||||
return
|
||||
|
||||
assert isinstance(paths[0], NumpyPath2d)
|
||||
self.store(FilledPathsRecord(paths), properties)
|
||||
|
||||
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
|
||||
# preserve the boundary in image_data in pixel coordinates
|
||||
boundary = copy.deepcopy(image_data.pixel_boundary_path)
|
||||
boundary.transform_inplace(image_data.transform)
|
||||
self.store(ImageRecord(boundary, image_data), properties)
|
||||
|
||||
def enter_entity(self, entity, properties) -> None:
|
||||
pass
|
||||
|
||||
def exit_entity(self, entity) -> None:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def finalize(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class Override(NamedTuple):
|
||||
"""Represents the override state for a data record.
|
||||
|
||||
Attributes:
|
||||
properties: original or modified :class:`BackendProperties`
|
||||
is_visible: override visibility e.g. switch layers on/off
|
||||
|
||||
"""
|
||||
|
||||
properties: BackendProperties
|
||||
is_visible: bool = True
|
||||
|
||||
|
||||
OverrideFunc: TypeAlias = Callable[[BackendProperties], Override]
|
||||
|
||||
|
||||
class Player:
|
||||
"""Plays the recordings of the :class:`Recorder` backend on another backend."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.config = Configuration()
|
||||
self.background: Color = "#000000"
|
||||
self.records: list[DataRecord] = []
|
||||
self.properties: dict[int, BackendProperties] = dict()
|
||||
self._bbox = BoundingBox2d()
|
||||
self.has_shared_recordings: bool = False
|
||||
|
||||
def __copy__(self) -> Self:
|
||||
"""Returns a copy of the player with non-shared recordings."""
|
||||
player = self.__class__()
|
||||
# config is a frozen dataclass:
|
||||
player.config = self.config
|
||||
player.background = self.background
|
||||
# recordings are mutable: transform and crop inplace
|
||||
player.records = copy.deepcopy(self.records)
|
||||
# the properties dict may grow, but entries will never be removed:
|
||||
player.properties = self.properties
|
||||
player.has_shared_recordings = False
|
||||
return player
|
||||
|
||||
copy = __copy__
|
||||
|
||||
def recordings(self) -> Iterator[tuple[DataRecord, BackendProperties]]:
|
||||
"""Yields all recordings as `(DataRecord, BackendProperties)` tuples."""
|
||||
props = self.properties
|
||||
for record in self.records:
|
||||
properties = BackendProperties(
|
||||
*props[record.property_hash][:4], record.handle
|
||||
)
|
||||
yield record, properties
|
||||
|
||||
def replay(
|
||||
self, backend: BackendInterface, override: Optional[OverrideFunc] = None
|
||||
) -> None:
|
||||
"""Replay the recording on another backend that implements the
|
||||
:class:`BackendInterface`. The optional `override` function can be used to
|
||||
override the properties and state of data records, it gets the :class:`BackendProperties`
|
||||
as input and must return an :class:`Override` instance.
|
||||
"""
|
||||
|
||||
backend.configure(self.config)
|
||||
backend.set_background(self.background)
|
||||
for record, properties in self.recordings():
|
||||
if override:
|
||||
state = override(properties)
|
||||
if not state.is_visible:
|
||||
continue
|
||||
properties = state.properties
|
||||
if isinstance(record, PointsRecord):
|
||||
count = len(record.points)
|
||||
if count == 0:
|
||||
continue
|
||||
if count > 2:
|
||||
backend.draw_filled_polygon(record.points, properties)
|
||||
continue
|
||||
vertices = record.points.vertices()
|
||||
if len(vertices) == 1:
|
||||
backend.draw_point(vertices[0], properties)
|
||||
else:
|
||||
backend.draw_line(vertices[0], vertices[1], properties)
|
||||
elif isinstance(record, SolidLinesRecord):
|
||||
backend.draw_solid_lines(take2(record.lines.vertices()), properties)
|
||||
elif isinstance(record, PathRecord):
|
||||
backend.draw_path(record.path, properties)
|
||||
elif isinstance(record, FilledPathsRecord):
|
||||
backend.draw_filled_paths(record.paths, properties)
|
||||
elif isinstance(record, ImageRecord):
|
||||
backend.draw_image(record.image_data, properties)
|
||||
backend.finalize()
|
||||
|
||||
def transform(self, m: Matrix44) -> None:
|
||||
"""Transforms the recordings inplace by a transformation matrix `m` of type
|
||||
:class:`~ezdxf.math.Matrix44`.
|
||||
"""
|
||||
for record in self.records:
|
||||
record.transform_inplace(m)
|
||||
|
||||
if self._bbox.has_data:
|
||||
# works for 90-, 180- and 270-degree rotation
|
||||
self._bbox = BoundingBox2d(m.fast_2d_transform(self._bbox.rect_vertices()))
|
||||
|
||||
def bbox(self) -> BoundingBox2d:
|
||||
"""Returns the bounding box of all records as :class:`~ezdxf.math.BoundingBox2d`."""
|
||||
if not self._bbox.has_data:
|
||||
self.update_bbox()
|
||||
return self._bbox
|
||||
|
||||
def update_bbox(self) -> None:
|
||||
bbox = BoundingBox2d()
|
||||
for record in self.records:
|
||||
bbox.extend(record.bbox())
|
||||
self._bbox = bbox
|
||||
|
||||
def crop_rect(self, p1: UVec, p2: UVec, distance: float) -> None:
|
||||
"""Crop recorded shapes inplace by a rectangle defined by two points.
|
||||
|
||||
The argument `distance` defines the approximation precision for paths which have
|
||||
to be approximated as polylines for cropping but only paths which are really get
|
||||
cropped are approximated, paths that are fully inside the crop box will not be
|
||||
approximated.
|
||||
|
||||
Args:
|
||||
p1: first corner of the clipping rectangle
|
||||
p2: second corner of the clipping rectangle
|
||||
distance: maximum distance from the center of the curve to the
|
||||
center of the line segment between two approximation points to
|
||||
determine if a segment should be subdivided.
|
||||
|
||||
"""
|
||||
crop_rect = BoundingBox2d([Vec2(p1), Vec2(p2)])
|
||||
self.records = crop_records_rect(self.records, crop_rect, distance)
|
||||
self._bbox = BoundingBox2d() # determine new bounding box on demand
|
||||
|
||||
|
||||
def crop_records_rect(
|
||||
records: list[DataRecord], crop_rect: BoundingBox2d, distance: float
|
||||
) -> list[DataRecord]:
|
||||
"""Crop recorded shapes inplace by a rectangle."""
|
||||
|
||||
def sort_paths(np_paths: Sequence[NumpyPath2d]):
|
||||
_inside: list[NumpyPath2d] = []
|
||||
_crop: list[NumpyPath2d] = []
|
||||
|
||||
for np_path in np_paths:
|
||||
bbox = BoundingBox2d(np_path.extents())
|
||||
if not crop_rect.has_intersection(bbox):
|
||||
# path is complete outside the cropping rectangle
|
||||
pass
|
||||
elif crop_rect.inside(bbox.extmin) and crop_rect.inside(bbox.extmax):
|
||||
# path is complete inside the cropping rectangle
|
||||
_inside.append(np_path)
|
||||
else:
|
||||
_crop.append(np_path)
|
||||
|
||||
return _crop, _inside
|
||||
|
||||
def crop_paths(
|
||||
np_paths: Sequence[NumpyPath2d],
|
||||
) -> list[NumpyPath2d]:
|
||||
return list(clipper.clip_filled_paths(np_paths, distance))
|
||||
|
||||
# an undefined crop box crops nothing:
|
||||
if not crop_rect.has_data:
|
||||
return records
|
||||
cropped_records: list[DataRecord] = []
|
||||
size = crop_rect.size
|
||||
# a crop box size of zero in any dimension crops everything:
|
||||
if size.x < 1e-12 or size.y < 1e-12:
|
||||
return cropped_records
|
||||
|
||||
clipper = ClippingRect(crop_rect.rect_vertices())
|
||||
for record in records:
|
||||
record_box = record.bbox()
|
||||
if not crop_rect.has_intersection(record_box):
|
||||
# record is complete outside the cropping rectangle
|
||||
continue
|
||||
if crop_rect.inside(record_box.extmin) and crop_rect.inside(record_box.extmax):
|
||||
# record is complete inside the cropping rectangle
|
||||
cropped_records.append(record)
|
||||
continue
|
||||
|
||||
if isinstance(record, FilledPathsRecord):
|
||||
paths_to_crop, inside = sort_paths(record.paths)
|
||||
cropped_paths = crop_paths(paths_to_crop) + inside
|
||||
if cropped_paths:
|
||||
record.paths = tuple(cropped_paths)
|
||||
cropped_records.append(record)
|
||||
elif isinstance(record, PathRecord):
|
||||
# could be split into multiple parts
|
||||
for p in clipper.clip_paths([record.path], distance):
|
||||
path_record = PathRecord(p)
|
||||
path_record.property_hash = record.property_hash
|
||||
path_record.handle = record.handle
|
||||
cropped_records.append(path_record)
|
||||
elif isinstance(record, PointsRecord):
|
||||
count = len(record.points)
|
||||
if count == 1:
|
||||
# record is inside the clipping shape!
|
||||
cropped_records.append(record)
|
||||
elif count == 2:
|
||||
s, e = record.points.vertices()
|
||||
for segment in clipper.clip_line(s, e):
|
||||
if not segment:
|
||||
continue
|
||||
_record = copy.copy(record) # shallow copy
|
||||
_record.points = NumpyPoints2d(segment)
|
||||
cropped_records.append(_record)
|
||||
else:
|
||||
for polygon in clipper.clip_polygon(record.points):
|
||||
if not polygon:
|
||||
continue
|
||||
_record = copy.copy(record) # shallow copy!
|
||||
_record.points = polygon
|
||||
cropped_records.append(_record)
|
||||
elif isinstance(record, SolidLinesRecord):
|
||||
points: list[Vec2] = []
|
||||
for s, e in take2(record.lines.vertices()):
|
||||
for segment in clipper.clip_line(s, e):
|
||||
points.extend(segment)
|
||||
record.lines = NumpyPoints2d(points)
|
||||
cropped_records.append(record)
|
||||
elif isinstance(record, ImageRecord):
|
||||
pass
|
||||
# TODO: Image cropping not supported
|
||||
# Crop image boundary and apply transparency to cropped
|
||||
# parts of the image? -> Image boundary is now a polygon!
|
||||
else:
|
||||
raise ValueError("invalid record type")
|
||||
return cropped_records
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user